Ver Fonte

updating to the latest ec2.py (and re-patching with our changes).

Thomas Wiest há 9 anos atrás
pai
commit
6f61581dcc
2 ficheiros alterados com 680 adições e 62 exclusões
  1. 95 2
      inventory/aws/hosts/ec2.ini
  2. 585 60
      inventory/aws/hosts/ec2.py

+ 95 - 2
inventory/aws/hosts/ec2.ini

@@ -24,24 +24,61 @@ regions_exclude = us-gov-west-1,cn-north-1
 # This is the normal destination variable to use. If you are running Ansible
 # This is the normal destination variable to use. If you are running Ansible
 # from outside EC2, then 'public_dns_name' makes the most sense. If you are
 # from outside EC2, then 'public_dns_name' makes the most sense. If you are
 # running Ansible from within EC2, then perhaps you want to use the internal
 # running Ansible from within EC2, then perhaps you want to use the internal
-# address, and should set this to 'private_dns_name'.
+# address, and should set this to 'private_dns_name'. The key of an EC2 tag
+# may optionally be used; however the boto instance variables hold precedence
+# in the event of a collision.
 destination_variable = public_dns_name
 destination_variable = public_dns_name
 
 
 # For server inside a VPC, using DNS names may not make sense. When an instance
 # For server inside a VPC, using DNS names may not make sense. When an instance
 # has 'subnet_id' set, this variable is used. If the subnet is public, setting
 # has 'subnet_id' set, this variable is used. If the subnet is public, setting
 # this to 'ip_address' will return the public IP address. For instances in a
 # this to 'ip_address' will return the public IP address. For instances in a
 # private subnet, this should be set to 'private_ip_address', and Ansible must
 # private subnet, this should be set to 'private_ip_address', and Ansible must
-# be run from with EC2.
+# be run from within EC2. The key of an EC2 tag may optionally be used; however
+# the boto instance variables hold precedence in the event of a collision.
+# WARNING: - instances that are in the private vpc, _without_ public ip address 
+# will not be listed in the inventory until You set:
+# vpc_destination_variable = 'private_ip_address'
 vpc_destination_variable = ip_address
 vpc_destination_variable = ip_address
 
 
 # To tag instances on EC2 with the resource records that point to them from
 # To tag instances on EC2 with the resource records that point to them from
 # Route53, uncomment and set 'route53' to True.
 # Route53, uncomment and set 'route53' to True.
 route53 = False
 route53 = False
 
 
+# To exclude RDS instances from the inventory, uncomment and set to False.
+#rds = False
+
+# To exclude ElastiCache instances from the inventory, uncomment and set to False.
+#elasticache = False
+
 # Additionally, you can specify the list of zones to exclude looking up in
 # Additionally, you can specify the list of zones to exclude looking up in
 # 'route53_excluded_zones' as a comma-separated list.
 # 'route53_excluded_zones' as a comma-separated list.
 # route53_excluded_zones = samplezone1.com, samplezone2.com
 # route53_excluded_zones = samplezone1.com, samplezone2.com
 
 
+# By default, only EC2 instances in the 'running' state are returned. Set
+# 'all_instances' to True to return all instances regardless of state.
+all_instances = False
+
+# By default, only EC2 instances in the 'running' state are returned. Specify
+# EC2 instance states to return as a comma-separated list. This
+# option is overriden when 'all_instances' is True.
+# instance_states = pending, running, shutting-down, terminated, stopping, stopped
+
+# By default, only RDS instances in the 'available' state are returned.  Set
+# 'all_rds_instances' to True return all RDS instances regardless of state.
+all_rds_instances = False
+
+# By default, only ElastiCache clusters and nodes in the 'available' state
+# are returned. Set 'all_elasticache_clusters' and/or 'all_elastic_nodes'
+# to True return all ElastiCache clusters and nodes, regardless of state.
+#
+# Note that all_elasticache_nodes only applies to listed clusters. That means
+# if you set all_elastic_clusters to false, no node will be return from
+# unavailable clusters, regardless of the state and to what you set for
+# all_elasticache_nodes.
+all_elasticache_replication_groups = False
+all_elasticache_clusters = False
+all_elasticache_nodes = False
+
 # API calls to EC2 are slow. For this reason, we cache the results of an API
 # API calls to EC2 are slow. For this reason, we cache the results of an API
 # call. Set this to the path you want cache files to be written to. Two files
 # call. Set this to the path you want cache files to be written to. Two files
 # will be written to this directory:
 # will be written to this directory:
@@ -60,3 +97,59 @@ cache_max_age = 300
 # destination_variable and vpc_destination_variable.
 # destination_variable and vpc_destination_variable.
 # destination_format = {0}.{1}.rhcloud.com
 # destination_format = {0}.{1}.rhcloud.com
 # destination_format_tags = Name,environment
 # destination_format_tags = Name,environment
+
+# Organize groups into a nested/hierarchy instead of a flat namespace.
+nested_groups = False
+
+# Replace - tags when creating groups to avoid issues with ansible
+replace_dash_in_groups = False
+
+# The EC2 inventory output can become very large. To manage its size,
+# configure which groups should be created.
+group_by_instance_id = True
+group_by_region = True
+group_by_availability_zone = True
+group_by_ami_id = True
+group_by_instance_type = True
+group_by_key_pair = True
+group_by_vpc_id = True
+group_by_security_group = True
+group_by_tag_keys = True
+group_by_tag_none = True
+group_by_route53_names = True
+group_by_rds_engine = True
+group_by_rds_parameter_group = True
+group_by_elasticache_engine = True
+group_by_elasticache_cluster = True
+group_by_elasticache_parameter_group = True
+group_by_elasticache_replication_group = True
+
+# If you only want to include hosts that match a certain regular expression
+# pattern_include = staging-*
+
+# If you want to exclude any hosts that match a certain regular expression
+# pattern_exclude = staging-*
+
+# Instance filters can be used to control which instances are retrieved for
+# inventory. For the full list of possible filters, please read the EC2 API
+# docs: http://docs.aws.amazon.com/AWSEC2/latest/APIReference/ApiReference-query-DescribeInstances.html#query-DescribeInstances-filters
+# Filters are key/value pairs separated by '=', to list multiple filters use
+# a list separated by commas. See examples below.
+
+# Retrieve only instances with (key=value) env=staging tag
+# instance_filters = tag:env=staging
+
+# Retrieve only instances with role=webservers OR role=dbservers tag
+# instance_filters = tag:role=webservers,tag:role=dbservers
+
+# Retrieve only t1.micro instances OR instances with tag env=staging
+# instance_filters = instance-type=t1.micro,tag:env=staging
+
+# You can use wildcards in filter values also. Below will list instances which
+# tag Name value matches webservers1*
+# (ex. webservers15, webservers1a, webservers123 etc) 
+# instance_filters = tag:Name=webservers1*
+
+# A boto configuration profile may be used to separate out credentials
+# see http://boto.readthedocs.org/en/latest/boto_config_tut.html
+# boto_profile = some-boto-profile-name

+ 585 - 60
inventory/aws/hosts/ec2.py

@@ -22,6 +22,12 @@ you need to define:
 
 
     export EC2_URL=http://hostname_of_your_cc:port/services/Eucalyptus
     export EC2_URL=http://hostname_of_your_cc:port/services/Eucalyptus
 
 
+If you're using boto profiles (requires boto>=2.24.0) you can choose a profile
+using the --boto-profile command line argument (e.g. ec2.py --boto-profile prod) or using
+the AWS_PROFILE variable:
+
+    AWS_PROFILE=prod ansible-playbook -i ec2.py myplaybook.yml
+
 For more details, see: http://docs.pythonboto.org/en/latest/boto_config_tut.html
 For more details, see: http://docs.pythonboto.org/en/latest/boto_config_tut.html
 
 
 When run against a specific host, this script returns the following variables:
 When run against a specific host, this script returns the following variables:
@@ -121,8 +127,11 @@ from time import time
 import boto
 import boto
 from boto import ec2
 from boto import ec2
 from boto import rds
 from boto import rds
+from boto import elasticache
 from boto import route53
 from boto import route53
-import ConfigParser
+import six
+
+from six.moves import configparser
 from collections import defaultdict
 from collections import defaultdict
 
 
 try:
 try:
@@ -145,9 +154,18 @@ class Ec2Inventory(object):
         # Index of hostname (address) to instance ID
         # Index of hostname (address) to instance ID
         self.index = {}
         self.index = {}
 
 
+        # Boto profile to use (if any)
+        self.boto_profile = None
+
         # Read settings and parse CLI arguments
         # Read settings and parse CLI arguments
-        self.read_settings()
         self.parse_cli_args()
         self.parse_cli_args()
+        self.read_settings()
+
+        # Make sure that profile_name is not passed at all if not set
+        # as pre 2.24 boto will fall over otherwise
+        if self.boto_profile:
+            if not hasattr(boto.ec2.EC2Connection, 'profile_name'):
+                self.fail_with_error("boto version must be >= 2.24 to use profile")
 
 
         # Cache
         # Cache
         if self.args.refresh_cache:
         if self.args.refresh_cache:
@@ -166,7 +184,7 @@ class Ec2Inventory(object):
             else:
             else:
                 data_to_print = self.json_format_dict(self.inventory, True)
                 data_to_print = self.json_format_dict(self.inventory, True)
 
 
-        print data_to_print
+        print(data_to_print)
 
 
 
 
     def is_cache_valid(self):
     def is_cache_valid(self):
@@ -184,10 +202,12 @@ class Ec2Inventory(object):
 
 
     def read_settings(self):
     def read_settings(self):
         ''' Reads the settings from the ec2.ini file '''
         ''' Reads the settings from the ec2.ini file '''
-
-        config = ConfigParser.SafeConfigParser()
+        if six.PY3:
+            config = configparser.ConfigParser()
+        else:
+            config = configparser.SafeConfigParser()
         ec2_default_ini_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'ec2.ini')
         ec2_default_ini_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'ec2.ini')
-        ec2_ini_path = os.environ.get('EC2_INI_PATH', ec2_default_ini_path)
+        ec2_ini_path = os.path.expanduser(os.path.expandvars(os.environ.get('EC2_INI_PATH', ec2_default_ini_path)))
         config.read(ec2_ini_path)
         config.read(ec2_ini_path)
 
 
         # is eucalyptus?
         # is eucalyptus?
@@ -236,18 +256,72 @@ class Ec2Inventory(object):
         if config.has_option('ec2', 'rds'):
         if config.has_option('ec2', 'rds'):
             self.rds_enabled = config.getboolean('ec2', 'rds')
             self.rds_enabled = config.getboolean('ec2', 'rds')
 
 
-        # Return all EC2 and RDS instances (if RDS is enabled)
+        # Include ElastiCache instances?
+        self.elasticache_enabled = True
+        if config.has_option('ec2', 'elasticache'):
+            self.elasticache_enabled = config.getboolean('ec2', 'elasticache')
+
+        # Return all EC2 instances?
         if config.has_option('ec2', 'all_instances'):
         if config.has_option('ec2', 'all_instances'):
             self.all_instances = config.getboolean('ec2', 'all_instances')
             self.all_instances = config.getboolean('ec2', 'all_instances')
         else:
         else:
             self.all_instances = False
             self.all_instances = False
+
+        # Instance states to be gathered in inventory. Default is 'running'.
+        # Setting 'all_instances' to 'yes' overrides this option.
+        ec2_valid_instance_states = [
+            'pending',
+            'running',
+            'shutting-down',
+            'terminated',
+            'stopping',
+            'stopped'
+        ]
+        self.ec2_instance_states = []
+        if self.all_instances:
+            self.ec2_instance_states = ec2_valid_instance_states
+        elif config.has_option('ec2', 'instance_states'):
+          for instance_state in config.get('ec2', 'instance_states').split(','):
+            instance_state = instance_state.strip()
+            if instance_state not in ec2_valid_instance_states:
+              continue
+            self.ec2_instance_states.append(instance_state)
+        else:
+          self.ec2_instance_states = ['running']
+
+        # Return all RDS instances? (if RDS is enabled)
         if config.has_option('ec2', 'all_rds_instances') and self.rds_enabled:
         if config.has_option('ec2', 'all_rds_instances') and self.rds_enabled:
             self.all_rds_instances = config.getboolean('ec2', 'all_rds_instances')
             self.all_rds_instances = config.getboolean('ec2', 'all_rds_instances')
         else:
         else:
             self.all_rds_instances = False
             self.all_rds_instances = False
 
 
+        # Return all ElastiCache replication groups? (if ElastiCache is enabled)
+        if config.has_option('ec2', 'all_elasticache_replication_groups') and self.elasticache_enabled:
+            self.all_elasticache_replication_groups = config.getboolean('ec2', 'all_elasticache_replication_groups')
+        else:
+            self.all_elasticache_replication_groups = False
+
+        # Return all ElastiCache clusters? (if ElastiCache is enabled)
+        if config.has_option('ec2', 'all_elasticache_clusters') and self.elasticache_enabled:
+            self.all_elasticache_clusters = config.getboolean('ec2', 'all_elasticache_clusters')
+        else:
+            self.all_elasticache_clusters = False
+
+        # Return all ElastiCache nodes? (if ElastiCache is enabled)
+        if config.has_option('ec2', 'all_elasticache_nodes') and self.elasticache_enabled:
+            self.all_elasticache_nodes = config.getboolean('ec2', 'all_elasticache_nodes')
+        else:
+            self.all_elasticache_nodes = False
+
+        # boto configuration profile (prefer CLI argument)
+        self.boto_profile = self.args.boto_profile
+        if config.has_option('ec2', 'boto_profile') and not self.boto_profile:
+            self.boto_profile = config.get('ec2', 'boto_profile')
+
         # Cache related
         # Cache related
         cache_dir = os.path.expanduser(config.get('ec2', 'cache_path'))
         cache_dir = os.path.expanduser(config.get('ec2', 'cache_path'))
+        if self.boto_profile:
+            cache_dir = os.path.join(cache_dir, 'profile_' + self.boto_profile)
         if not os.path.exists(cache_dir):
         if not os.path.exists(cache_dir):
             os.makedirs(cache_dir)
             os.makedirs(cache_dir)
 
 
@@ -261,6 +335,12 @@ class Ec2Inventory(object):
         else:
         else:
             self.nested_groups = False
             self.nested_groups = False
 
 
+        # Replace dash or not in group names
+        if config.has_option('ec2', 'replace_dash_in_groups'):
+            self.replace_dash_in_groups = config.getboolean('ec2', 'replace_dash_in_groups')
+        else:
+            self.replace_dash_in_groups = True
+
         # Configure which groups should be created.
         # Configure which groups should be created.
         group_by_options = [
         group_by_options = [
             'group_by_instance_id',
             'group_by_instance_id',
@@ -276,6 +356,10 @@ class Ec2Inventory(object):
             'group_by_route53_names',
             'group_by_route53_names',
             'group_by_rds_engine',
             'group_by_rds_engine',
             'group_by_rds_parameter_group',
             'group_by_rds_parameter_group',
+            'group_by_elasticache_engine',
+            'group_by_elasticache_cluster',
+            'group_by_elasticache_parameter_group',
+            'group_by_elasticache_replication_group',
         ]
         ]
         for option in group_by_options:
         for option in group_by_options:
             if config.has_option('ec2', option):
             if config.has_option('ec2', option):
@@ -290,7 +374,7 @@ class Ec2Inventory(object):
                 self.pattern_include = re.compile(pattern_include)
                 self.pattern_include = re.compile(pattern_include)
             else:
             else:
                 self.pattern_include = None
                 self.pattern_include = None
-        except ConfigParser.NoOptionError, e:
+        except configparser.NoOptionError:
             self.pattern_include = None
             self.pattern_include = None
 
 
         # Do we need to exclude hosts that match a pattern?
         # Do we need to exclude hosts that match a pattern?
@@ -300,7 +384,7 @@ class Ec2Inventory(object):
                 self.pattern_exclude = re.compile(pattern_exclude)
                 self.pattern_exclude = re.compile(pattern_exclude)
             else:
             else:
                 self.pattern_exclude = None
                 self.pattern_exclude = None
-        except ConfigParser.NoOptionError, e:
+        except configparser.NoOptionError:
             self.pattern_exclude = None
             self.pattern_exclude = None
 
 
         # Instance filters (see boto and EC2 API docs). Ignore invalid filters.
         # Instance filters (see boto and EC2 API docs). Ignore invalid filters.
@@ -325,6 +409,8 @@ class Ec2Inventory(object):
                            help='Get all the variables about a specific instance')
                            help='Get all the variables about a specific instance')
         parser.add_argument('--refresh-cache', action='store_true', default=False,
         parser.add_argument('--refresh-cache', action='store_true', default=False,
                            help='Force refresh of cache by making API requests to EC2 (default: False - use cache files)')
                            help='Force refresh of cache by making API requests to EC2 (default: False - use cache files)')
+        parser.add_argument('--boto-profile', action='store',
+                           help='Use boto profile for connections to EC2')
         self.args = parser.parse_args()
         self.args = parser.parse_args()
 
 
 
 
@@ -338,30 +424,52 @@ class Ec2Inventory(object):
             self.get_instances_by_region(region)
             self.get_instances_by_region(region)
             if self.rds_enabled:
             if self.rds_enabled:
                 self.get_rds_instances_by_region(region)
                 self.get_rds_instances_by_region(region)
+            if self.elasticache_enabled:
+                self.get_elasticache_clusters_by_region(region)
+                self.get_elasticache_replication_groups_by_region(region)
 
 
         self.write_to_cache(self.inventory, self.cache_path_cache)
         self.write_to_cache(self.inventory, self.cache_path_cache)
         self.write_to_cache(self.index, self.cache_path_index)
         self.write_to_cache(self.index, self.cache_path_index)
 
 
+    def connect(self, region):
+        ''' create connection to api server'''
+        if self.eucalyptus:
+            conn = boto.connect_euca(host=self.eucalyptus_host)
+            conn.APIVersion = '2010-08-31'
+        else:
+            conn = self.connect_to_aws(ec2, region)
+        return conn
+
+    def boto_fix_security_token_in_profile(self, connect_args):
+        ''' monkey patch for boto issue boto/boto#2100 '''
+        profile = 'profile ' + self.boto_profile
+        if boto.config.has_option(profile, 'aws_security_token'):
+            connect_args['security_token'] = boto.config.get(profile, 'aws_security_token')
+        return connect_args
+
+    def connect_to_aws(self, module, region):
+        connect_args = {}
+
+        # only pass the profile name if it's set (as it is not supported by older boto versions)
+        if self.boto_profile:
+            connect_args['profile_name'] = self.boto_profile
+            self.boto_fix_security_token_in_profile(connect_args)
+
+        conn = module.connect_to_region(region, **connect_args)
+        # connect_to_region will fail "silently" by returning None if the region name is wrong or not supported
+        if conn is None:
+            self.fail_with_error("region name: %s likely not supported, or AWS is down.  connection to region failed." % region)
+        return conn
 
 
     def get_instances_by_region(self, region):
     def get_instances_by_region(self, region):
         ''' Makes an AWS EC2 API call to the list of instances in a particular
         ''' Makes an AWS EC2 API call to the list of instances in a particular
         region '''
         region '''
 
 
         try:
         try:
-            if self.eucalyptus:
-                conn = boto.connect_euca(host=self.eucalyptus_host)
-                conn.APIVersion = '2010-08-31'
-            else:
-                conn = ec2.connect_to_region(region)
-
-            # connect_to_region will fail "silently" by returning None if the region name is wrong or not supported
-            if conn is None:
-                print("region name: %s likely not supported, or AWS is down.  connection to region failed." % region)
-                sys.exit(1)
-
+            conn = self.connect(region)
             reservations = []
             reservations = []
             if self.ec2_instance_filters:
             if self.ec2_instance_filters:
-                for filter_key, filter_values in self.ec2_instance_filters.iteritems():
+                for filter_key, filter_values in self.ec2_instance_filters.items():
                     reservations.extend(conn.get_all_instances(filters = { filter_key : filter_values }))
                     reservations.extend(conn.get_all_instances(filters = { filter_key : filter_values }))
             else:
             else:
                 reservations = conn.get_all_instances()
                 reservations = conn.get_all_instances()
@@ -370,40 +478,130 @@ class Ec2Inventory(object):
                 for instance in reservation.instances:
                 for instance in reservation.instances:
                     self.add_instance(instance, region)
                     self.add_instance(instance, region)
 
 
-        except boto.exception.BotoServerError, e:
-            if  not self.eucalyptus:
-                print "Looks like AWS is down again:"
-            print e
-            sys.exit(1)
+        except boto.exception.BotoServerError as e:
+            if e.error_code == 'AuthFailure':
+                error = self.get_auth_error_message()
+            else:
+                backend = 'Eucalyptus' if self.eucalyptus else 'AWS' 
+                error = "Error connecting to %s backend.\n%s" % (backend, e.message)
+            self.fail_with_error(error, 'getting EC2 instances')
 
 
     def get_rds_instances_by_region(self, region):
     def get_rds_instances_by_region(self, region):
         ''' Makes an AWS API call to the list of RDS instances in a particular
         ''' Makes an AWS API call to the list of RDS instances in a particular
         region '''
         region '''
 
 
         try:
         try:
-            conn = rds.connect_to_region(region)
+            conn = self.connect_to_aws(rds, region)
             if conn:
             if conn:
                 instances = conn.get_all_dbinstances()
                 instances = conn.get_all_dbinstances()
                 for instance in instances:
                 for instance in instances:
                     self.add_rds_instance(instance, region)
                     self.add_rds_instance(instance, region)
-        except boto.exception.BotoServerError, e:
+        except boto.exception.BotoServerError as e:
+            error = e.reason
+
+            if e.error_code == 'AuthFailure':
+                error = self.get_auth_error_message()
             if not e.reason == "Forbidden":
             if not e.reason == "Forbidden":
-                print "Looks like AWS RDS is down: "
-                print e
-                sys.exit(1)
+                error = "Looks like AWS RDS is down:\n%s" % e.message
+            self.fail_with_error(error, 'getting RDS instances')
 
 
-    def get_instance(self, region, instance_id):
-        ''' Gets details about a specific instance '''
-        if self.eucalyptus:
-            conn = boto.connect_euca(self.eucalyptus_host)
-            conn.APIVersion = '2010-08-31'
+    def get_elasticache_clusters_by_region(self, region):
+        ''' Makes an AWS API call to the list of ElastiCache clusters (with
+        nodes' info) in a particular region.'''
+
+        # ElastiCache boto module doesn't provide a get_all_intances method,
+        # that's why we need to call describe directly (it would be called by
+        # the shorthand method anyway...)
+        try:
+            conn = elasticache.connect_to_region(region)
+            if conn:
+                # show_cache_node_info = True
+                # because we also want nodes' information
+                response = conn.describe_cache_clusters(None, None, None, True)
+
+        except boto.exception.BotoServerError as e:
+            error = e.reason
+
+            if e.error_code == 'AuthFailure':
+                error = self.get_auth_error_message()
+            if not e.reason == "Forbidden":
+                error = "Looks like AWS ElastiCache is down:\n%s" % e.message
+            self.fail_with_error(error, 'getting ElastiCache clusters')
+
+        try:
+            # Boto also doesn't provide wrapper classes to CacheClusters or
+            # CacheNodes. Because of that wo can't make use of the get_list
+            # method in the AWSQueryConnection. Let's do the work manually
+            clusters = response['DescribeCacheClustersResponse']['DescribeCacheClustersResult']['CacheClusters']
+
+        except KeyError as e:
+            error = "ElastiCache query to AWS failed (unexpected format)."
+            self.fail_with_error(error, 'getting ElastiCache clusters')
+
+        for cluster in clusters:
+            self.add_elasticache_cluster(cluster, region)
+
+    def get_elasticache_replication_groups_by_region(self, region):
+        ''' Makes an AWS API call to the list of ElastiCache replication groups
+        in a particular region.'''
+
+        # ElastiCache boto module doesn't provide a get_all_intances method,
+        # that's why we need to call describe directly (it would be called by
+        # the shorthand method anyway...)
+        try:
+            conn = elasticache.connect_to_region(region)
+            if conn:
+                response = conn.describe_replication_groups()
+
+        except boto.exception.BotoServerError as e:
+            error = e.reason
+
+            if e.error_code == 'AuthFailure':
+                error = self.get_auth_error_message()
+            if not e.reason == "Forbidden":
+                error = "Looks like AWS ElastiCache [Replication Groups] is down:\n%s" % e.message
+            self.fail_with_error(error, 'getting ElastiCache clusters')
+
+        try:
+            # Boto also doesn't provide wrapper classes to ReplicationGroups
+            # Because of that wo can't make use of the get_list method in the
+            # AWSQueryConnection. Let's do the work manually
+            replication_groups = response['DescribeReplicationGroupsResponse']['DescribeReplicationGroupsResult']['ReplicationGroups']
+
+        except KeyError as e:
+            error = "ElastiCache [Replication Groups] query to AWS failed (unexpected format)."
+            self.fail_with_error(error, 'getting ElastiCache clusters')
+
+        for replication_group in replication_groups:
+            self.add_elasticache_replication_group(replication_group, region)
+
+    def get_auth_error_message(self):
+        ''' create an informative error message if there is an issue authenticating'''
+        errors = ["Authentication error retrieving ec2 inventory."]
+        if None in [os.environ.get('AWS_ACCESS_KEY_ID'), os.environ.get('AWS_SECRET_ACCESS_KEY')]:
+            errors.append(' - No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY environment vars found')
         else:
         else:
-            conn = ec2.connect_to_region(region)
+            errors.append(' - AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment vars found but may not be correct')
 
 
-        # connect_to_region will fail "silently" by returning None if the region name is wrong or not supported
-        if conn is None:
-            print("region name: %s likely not supported, or AWS is down.  connection to region failed." % region)
-            sys.exit(1)
+        boto_paths = ['/etc/boto.cfg', '~/.boto', '~/.aws/credentials']
+        boto_config_found = list(p for p in boto_paths if os.path.isfile(os.path.expanduser(p)))
+        if len(boto_config_found) > 0:
+            errors.append(" - Boto configs found at '%s', but the credentials contained may not be correct" % ', '.join(boto_config_found))
+        else:
+            errors.append(" - No Boto config found at any expected location '%s'" % ', '.join(boto_paths))
+
+        return '\n'.join(errors)
+
+    def fail_with_error(self, err_msg, err_operation=None):
+        '''log an error to std err for ansible-playbook to consume and exit'''
+        if err_operation:
+            err_msg = 'ERROR: "{err_msg}", while: {err_operation}'.format(
+                err_msg=err_msg, err_operation=err_operation)
+        sys.stderr.write(err_msg)
+        sys.exit(1)
+
+    def get_instance(self, region, instance_id):
+        conn = self.connect(region)
 
 
         reservations = conn.get_all_instances([instance_id])
         reservations = conn.get_all_instances([instance_id])
         for reservation in reservations:
         for reservation in reservations:
@@ -414,8 +612,8 @@ class Ec2Inventory(object):
         ''' Adds an instance to the inventory and index, as long as it is
         ''' Adds an instance to the inventory and index, as long as it is
         addressable '''
         addressable '''
 
 
-        # Only want running instances unless all_instances is True
-        if not self.all_instances and instance.state != 'running':
+        # Only return instances with desired instance states
+        if instance.state not in self.ec2_instance_states:
             return
             return
 
 
         # Select the best destination address
         # Select the best destination address
@@ -502,18 +700,21 @@ class Ec2Inventory(object):
                     if self.nested_groups:
                     if self.nested_groups:
                         self.push_group(self.inventory, 'security_groups', key)
                         self.push_group(self.inventory, 'security_groups', key)
             except AttributeError:
             except AttributeError:
-                print 'Package boto seems a bit older.'
-                print 'Please upgrade boto >= 2.3.0.'
-                sys.exit(1)
+                self.fail_with_error('\n'.join(['Package boto seems a bit older.', 
+                                            'Please upgrade boto >= 2.3.0.']))
 
 
         # Inventory: Group by tag keys
         # Inventory: Group by tag keys
         if self.group_by_tag_keys:
         if self.group_by_tag_keys:
-            for k, v in instance.tags.iteritems():
-                key = self.to_safe("tag_" + k + "=" + v)
+            for k, v in instance.tags.items():
+                if v:
+                    key = self.to_safe("tag_" + k + "=" + v)
+                else:
+                    key = self.to_safe("tag_" + k)
                 self.push(self.inventory, key, dest)
                 self.push(self.inventory, key, dest)
                 if self.nested_groups:
                 if self.nested_groups:
                     self.push_group(self.inventory, 'tags', self.to_safe("tag_" + k))
                     self.push_group(self.inventory, 'tags', self.to_safe("tag_" + k))
-                    self.push_group(self.inventory, self.to_safe("tag_" + k), key)
+                    if v:
+                        self.push_group(self.inventory, self.to_safe("tag_" + k), key)
 
 
         # Inventory: Group by Route53 domain names if enabled
         # Inventory: Group by Route53 domain names if enabled
         if self.route53_enabled and self.group_by_route53_names:
         if self.route53_enabled and self.group_by_route53_names:
@@ -597,9 +798,9 @@ class Ec2Inventory(object):
                         self.push_group(self.inventory, 'security_groups', key)
                         self.push_group(self.inventory, 'security_groups', key)
 
 
             except AttributeError:
             except AttributeError:
-                print 'Package boto seems a bit older.'
-                print 'Please upgrade boto >= 2.3.0.'
-                sys.exit(1)
+                self.fail_with_error('\n'.join(['Package boto seems a bit older.', 
+                                            'Please upgrade boto >= 2.3.0.']))
+
 
 
         # Inventory: Group by engine
         # Inventory: Group by engine
         if self.group_by_rds_engine:
         if self.group_by_rds_engine:
@@ -618,6 +819,243 @@ class Ec2Inventory(object):
 
 
         self.inventory["_meta"]["hostvars"][dest] = self.get_host_info_dict_from_instance(instance)
         self.inventory["_meta"]["hostvars"][dest] = self.get_host_info_dict_from_instance(instance)
 
 
+    def add_elasticache_cluster(self, cluster, region):
+        ''' Adds an ElastiCache cluster to the inventory and index, as long as
+        it's nodes are addressable '''
+
+        # Only want available clusters unless all_elasticache_clusters is True
+        if not self.all_elasticache_clusters and cluster['CacheClusterStatus'] != 'available':
+            return
+
+        # Select the best destination address
+        if 'ConfigurationEndpoint' in cluster and cluster['ConfigurationEndpoint']:
+            # Memcached cluster
+            dest = cluster['ConfigurationEndpoint']['Address']
+            is_redis = False
+        else:
+            # Redis sigle node cluster
+            # Because all Redis clusters are single nodes, we'll merge the
+            # info from the cluster with info about the node
+            dest = cluster['CacheNodes'][0]['Endpoint']['Address']
+            is_redis = True
+
+        if not dest:
+            # Skip clusters we cannot address (e.g. private VPC subnet)
+            return
+
+        # Add to index
+        self.index[dest] = [region, cluster['CacheClusterId']]
+
+        # Inventory: Group by instance ID (always a group of 1)
+        if self.group_by_instance_id:
+            self.inventory[cluster['CacheClusterId']] = [dest]
+            if self.nested_groups:
+                self.push_group(self.inventory, 'instances', cluster['CacheClusterId'])
+
+        # Inventory: Group by region
+        if self.group_by_region and not is_redis:
+            self.push(self.inventory, region, dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'regions', region)
+
+        # Inventory: Group by availability zone
+        if self.group_by_availability_zone and not is_redis:
+            self.push(self.inventory, cluster['PreferredAvailabilityZone'], dest)
+            if self.nested_groups:
+                if self.group_by_region:
+                    self.push_group(self.inventory, region, cluster['PreferredAvailabilityZone'])
+                self.push_group(self.inventory, 'zones', cluster['PreferredAvailabilityZone'])
+
+        # Inventory: Group by node type
+        if self.group_by_instance_type and not is_redis:
+            type_name = self.to_safe('type_' + cluster['CacheNodeType'])
+            self.push(self.inventory, type_name, dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'types', type_name)
+
+        # Inventory: Group by VPC (information not available in the current
+        # AWS API version for ElastiCache)
+
+        # Inventory: Group by security group
+        if self.group_by_security_group and not is_redis:
+
+            # Check for the existence of the 'SecurityGroups' key and also if
+            # this key has some value. When the cluster is not placed in a SG
+            # the query can return None here and cause an error.
+            if 'SecurityGroups' in cluster and cluster['SecurityGroups'] is not None:
+                for security_group in cluster['SecurityGroups']:
+                    key = self.to_safe("security_group_" + security_group['SecurityGroupId'])
+                    self.push(self.inventory, key, dest)
+                    if self.nested_groups:
+                        self.push_group(self.inventory, 'security_groups', key)
+
+        # Inventory: Group by engine
+        if self.group_by_elasticache_engine and not is_redis:
+            self.push(self.inventory, self.to_safe("elasticache_" + cluster['Engine']), dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'elasticache_engines', self.to_safe(cluster['Engine']))
+
+        # Inventory: Group by parameter group
+        if self.group_by_elasticache_parameter_group:
+            self.push(self.inventory, self.to_safe("elasticache_parameter_group_" + cluster['CacheParameterGroup']['CacheParameterGroupName']), dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'elasticache_parameter_groups', self.to_safe(cluster['CacheParameterGroup']['CacheParameterGroupName']))
+
+        # Inventory: Group by replication group
+        if self.group_by_elasticache_replication_group and 'ReplicationGroupId' in cluster and cluster['ReplicationGroupId']:
+            self.push(self.inventory, self.to_safe("elasticache_replication_group_" + cluster['ReplicationGroupId']), dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'elasticache_replication_groups', self.to_safe(cluster['ReplicationGroupId']))
+
+        # Global Tag: all ElastiCache clusters
+        self.push(self.inventory, 'elasticache_clusters', cluster['CacheClusterId'])
+
+        host_info = self.get_host_info_dict_from_describe_dict(cluster)
+
+        self.inventory["_meta"]["hostvars"][dest] = host_info
+
+        # Add the nodes
+        for node in cluster['CacheNodes']:
+            self.add_elasticache_node(node, cluster, region)
+
+    def add_elasticache_node(self, node, cluster, region):
+        ''' Adds an ElastiCache node to the inventory and index, as long as
+        it is addressable '''
+
+        # Only want available nodes unless all_elasticache_nodes is True
+        if not self.all_elasticache_nodes and node['CacheNodeStatus'] != 'available':
+            return
+
+        # Select the best destination address
+        dest = node['Endpoint']['Address']
+
+        if not dest:
+            # Skip nodes we cannot address (e.g. private VPC subnet)
+            return
+
+        node_id = self.to_safe(cluster['CacheClusterId'] + '_' + node['CacheNodeId'])
+
+        # Add to index
+        self.index[dest] = [region, node_id]
+
+        # Inventory: Group by node ID (always a group of 1)
+        if self.group_by_instance_id:
+            self.inventory[node_id] = [dest]
+            if self.nested_groups:
+                self.push_group(self.inventory, 'instances', node_id)
+
+        # Inventory: Group by region
+        if self.group_by_region:
+            self.push(self.inventory, region, dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'regions', region)
+
+        # Inventory: Group by availability zone
+        if self.group_by_availability_zone:
+            self.push(self.inventory, cluster['PreferredAvailabilityZone'], dest)
+            if self.nested_groups:
+                if self.group_by_region:
+                    self.push_group(self.inventory, region, cluster['PreferredAvailabilityZone'])
+                self.push_group(self.inventory, 'zones', cluster['PreferredAvailabilityZone'])
+
+        # Inventory: Group by node type
+        if self.group_by_instance_type:
+            type_name = self.to_safe('type_' + cluster['CacheNodeType'])
+            self.push(self.inventory, type_name, dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'types', type_name)
+
+        # Inventory: Group by VPC (information not available in the current
+        # AWS API version for ElastiCache)
+
+        # Inventory: Group by security group
+        if self.group_by_security_group:
+
+            # Check for the existence of the 'SecurityGroups' key and also if
+            # this key has some value. When the cluster is not placed in a SG
+            # the query can return None here and cause an error.
+            if 'SecurityGroups' in cluster and cluster['SecurityGroups'] is not None:
+                for security_group in cluster['SecurityGroups']:
+                    key = self.to_safe("security_group_" + security_group['SecurityGroupId'])
+                    self.push(self.inventory, key, dest)
+                    if self.nested_groups:
+                        self.push_group(self.inventory, 'security_groups', key)
+
+        # Inventory: Group by engine
+        if self.group_by_elasticache_engine:
+            self.push(self.inventory, self.to_safe("elasticache_" + cluster['Engine']), dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'elasticache_engines', self.to_safe("elasticache_" + cluster['Engine']))
+
+        # Inventory: Group by parameter group (done at cluster level)
+
+        # Inventory: Group by replication group (done at cluster level)
+
+        # Inventory: Group by ElastiCache Cluster
+        if self.group_by_elasticache_cluster:
+            self.push(self.inventory, self.to_safe("elasticache_cluster_" + cluster['CacheClusterId']), dest)
+
+        # Global Tag: all ElastiCache nodes
+        self.push(self.inventory, 'elasticache_nodes', dest)
+
+        host_info = self.get_host_info_dict_from_describe_dict(node)
+
+        if dest in self.inventory["_meta"]["hostvars"]:
+            self.inventory["_meta"]["hostvars"][dest].update(host_info)
+        else:
+            self.inventory["_meta"]["hostvars"][dest] = host_info
+
+    def add_elasticache_replication_group(self, replication_group, region):
+        ''' Adds an ElastiCache replication group to the inventory and index '''
+
+        # Only want available clusters unless all_elasticache_replication_groups is True
+        if not self.all_elasticache_replication_groups and replication_group['Status'] != 'available':
+            return
+
+        # Select the best destination address (PrimaryEndpoint)
+        dest = replication_group['NodeGroups'][0]['PrimaryEndpoint']['Address']
+
+        if not dest:
+            # Skip clusters we cannot address (e.g. private VPC subnet)
+            return
+
+        # Add to index
+        self.index[dest] = [region, replication_group['ReplicationGroupId']]
+
+        # Inventory: Group by ID (always a group of 1)
+        if self.group_by_instance_id:
+            self.inventory[replication_group['ReplicationGroupId']] = [dest]
+            if self.nested_groups:
+                self.push_group(self.inventory, 'instances', replication_group['ReplicationGroupId'])
+
+        # Inventory: Group by region
+        if self.group_by_region:
+            self.push(self.inventory, region, dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'regions', region)
+
+        # Inventory: Group by availability zone (doesn't apply to replication groups)
+
+        # Inventory: Group by node type (doesn't apply to replication groups)
+
+        # Inventory: Group by VPC (information not available in the current
+        # AWS API version for replication groups
+
+        # Inventory: Group by security group (doesn't apply to replication groups)
+        # Check this value in cluster level
+
+        # Inventory: Group by engine (replication groups are always Redis)
+        if self.group_by_elasticache_engine:
+            self.push(self.inventory, 'elasticache_redis', dest)
+            if self.nested_groups:
+                self.push_group(self.inventory, 'elasticache_engines', 'redis')
+
+        # Global Tag: all ElastiCache clusters
+        self.push(self.inventory, 'elasticache_replication_groups', replication_group['ReplicationGroupId'])
+
+        host_info = self.get_host_info_dict_from_describe_dict(replication_group)
+
+        self.inventory["_meta"]["hostvars"][dest] = host_info
 
 
     def get_route53_records(self):
     def get_route53_records(self):
         ''' Get and store the map of resource records to domain names that
         ''' Get and store the map of resource records to domain names that
@@ -666,7 +1104,6 @@ class Ec2Inventory(object):
 
 
         return list(name_list)
         return list(name_list)
 
 
-
     def get_host_info_dict_from_instance(self, instance):
     def get_host_info_dict_from_instance(self, instance):
         instance_vars = {}
         instance_vars = {}
         for key in vars(instance):
         for key in vars(instance):
@@ -683,7 +1120,7 @@ class Ec2Inventory(object):
                 instance_vars['ec2_previous_state_code'] = instance.previous_state_code
                 instance_vars['ec2_previous_state_code'] = instance.previous_state_code
             elif type(value) in [int, bool]:
             elif type(value) in [int, bool]:
                 instance_vars[key] = value
                 instance_vars[key] = value
-            elif type(value) in [str, unicode]:
+            elif isinstance(value, six.string_types):
                 instance_vars[key] = value.strip()
                 instance_vars[key] = value.strip()
             elif type(value) == type(None):
             elif type(value) == type(None):
                 instance_vars[key] = ''
                 instance_vars[key] = ''
@@ -692,7 +1129,7 @@ class Ec2Inventory(object):
             elif key == 'ec2__placement':
             elif key == 'ec2__placement':
                 instance_vars['ec2_placement'] = value.zone
                 instance_vars['ec2_placement'] = value.zone
             elif key == 'ec2_tags':
             elif key == 'ec2_tags':
-                for k, v in value.iteritems():
+                for k, v in value.items():
                     key = self.to_safe('ec2_tag_' + k)
                     key = self.to_safe('ec2_tag_' + k)
                     instance_vars[key] = v
                     instance_vars[key] = v
             elif key == 'ec2_groups':
             elif key == 'ec2_groups':
@@ -712,6 +1149,91 @@ class Ec2Inventory(object):
 
 
         return instance_vars
         return instance_vars
 
 
+    def get_host_info_dict_from_describe_dict(self, describe_dict):
+        ''' Parses the dictionary returned by the API call into a flat list
+            of parameters. This method should be used only when 'describe' is
+            used directly because Boto doesn't provide specific classes. '''
+
+        # I really don't agree with prefixing everything with 'ec2'
+        # because EC2, RDS and ElastiCache are different services.
+        # I'm just following the pattern used until now to not break any
+        # compatibility.
+
+        host_info = {}
+        for key in describe_dict:
+            value = describe_dict[key]
+            key = self.to_safe('ec2_' + self.uncammelize(key))
+
+            # Handle complex types
+
+            # Target: Memcached Cache Clusters
+            if key == 'ec2_configuration_endpoint' and value:
+                host_info['ec2_configuration_endpoint_address'] = value['Address']
+                host_info['ec2_configuration_endpoint_port'] = value['Port']
+
+            # Target: Cache Nodes and Redis Cache Clusters (single node)
+            if key == 'ec2_endpoint' and value:
+                host_info['ec2_endpoint_address'] = value['Address']
+                host_info['ec2_endpoint_port'] = value['Port']
+
+            # Target: Redis Replication Groups
+            if key == 'ec2_node_groups' and value:
+                host_info['ec2_endpoint_address'] = value[0]['PrimaryEndpoint']['Address']
+                host_info['ec2_endpoint_port'] = value[0]['PrimaryEndpoint']['Port']
+                replica_count = 0
+                for node in value[0]['NodeGroupMembers']:
+                    if node['CurrentRole'] == 'primary':
+                        host_info['ec2_primary_cluster_address'] = node['ReadEndpoint']['Address']
+                        host_info['ec2_primary_cluster_port'] = node['ReadEndpoint']['Port']
+                        host_info['ec2_primary_cluster_id'] = node['CacheClusterId']
+                    elif node['CurrentRole'] == 'replica':
+                        host_info['ec2_replica_cluster_address_'+ str(replica_count)] = node['ReadEndpoint']['Address']
+                        host_info['ec2_replica_cluster_port_'+ str(replica_count)] = node['ReadEndpoint']['Port']
+                        host_info['ec2_replica_cluster_id_'+ str(replica_count)] = node['CacheClusterId']
+                        replica_count += 1
+
+            # Target: Redis Replication Groups
+            if key == 'ec2_member_clusters' and value:
+                host_info['ec2_member_clusters'] = ','.join([str(i) for i in value])
+
+            # Target: All Cache Clusters
+            elif key == 'ec2_cache_parameter_group':
+                host_info["ec2_cache_node_ids_to_reboot"] = ','.join([str(i) for i in value['CacheNodeIdsToReboot']])
+                host_info['ec2_cache_parameter_group_name'] = value['CacheParameterGroupName']
+                host_info['ec2_cache_parameter_apply_status'] = value['ParameterApplyStatus']
+
+            # Target: Almost everything
+            elif key == 'ec2_security_groups':
+
+                # Skip if SecurityGroups is None
+                # (it is possible to have the key defined but no value in it).
+                if value is not None:
+                    sg_ids = []
+                    for sg in value:
+                        sg_ids.append(sg['SecurityGroupId'])
+                    host_info["ec2_security_group_ids"] = ','.join([str(i) for i in sg_ids])
+
+            # Target: Everything
+            # Preserve booleans and integers
+            elif type(value) in [int, bool]:
+                host_info[key] = value
+
+            # Target: Everything
+            # Sanitize string values
+            elif isinstance(value, six.string_types):
+                host_info[key] = value.strip()
+
+            # Target: Everything
+            # Replace None by an empty string
+            elif type(value) == type(None):
+                host_info[key] = ''
+
+            else:
+                # Remove non-processed complex types
+                pass
+
+        return host_info
+
     def get_host_info(self):
     def get_host_info(self):
         ''' Get variables about a specific host '''
         ''' Get variables about a specific host '''
 
 
@@ -775,13 +1297,16 @@ class Ec2Inventory(object):
         cache.write(json_data)
         cache.write(json_data)
         cache.close()
         cache.close()
 
 
+    def uncammelize(self, key):
+        temp = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', key)
+        return re.sub('([a-z0-9])([A-Z])', r'\1_\2', temp).lower()
 
 
     def to_safe(self, word):
     def to_safe(self, word):
-        ''' Converts 'bad' characters in a string to underscores so they can be
-        used as Ansible groups '''
-
-        return re.sub("[^A-Za-z0-9\-]", "_", word)
-
+        ''' Converts 'bad' characters in a string to underscores so they can be used as Ansible groups '''
+        regex = "[^A-Za-z0-9\_"
+        if not self.replace_dash_in_groups:
+            regex += "\-"
+        return re.sub(regex + "]", "_", word)
 
 
     def json_format_dict(self, data, pretty=False):
     def json_format_dict(self, data, pretty=False):
         ''' Converts a dict to a JSON object and dumps it as a formatted
         ''' Converts a dict to a JSON object and dumps it as a formatted