1#!/usr/bin/env python
2
3# (c) 2013, Jesse Keating <jesse.keating@rackspace.com,
4#           Paul Durivage <paul.durivage@rackspace.com>,
5#           Matt Martz <matt@sivel.net>
6#
7# This file is part of Ansible.
8#
9# Ansible is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# Ansible is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
21
22"""
23Rackspace Cloud Inventory
24
25Authors:
26    Jesse Keating <jesse.keating@rackspace.com,
27    Paul Durivage <paul.durivage@rackspace.com>,
28    Matt Martz <matt@sivel.net>
29
30
31Description:
32    Generates inventory that Ansible can understand by making API request to
33    Rackspace Public Cloud API
34
35    When run against a specific host, this script returns variables similar to:
36        rax_os-ext-sts_task_state
37        rax_addresses
38        rax_links
39        rax_image
40        rax_os-ext-sts_vm_state
41        rax_flavor
42        rax_id
43        rax_rax-bandwidth_bandwidth
44        rax_user_id
45        rax_os-dcf_diskconfig
46        rax_accessipv4
47        rax_accessipv6
48        rax_progress
49        rax_os-ext-sts_power_state
50        rax_metadata
51        rax_status
52        rax_updated
53        rax_hostid
54        rax_name
55        rax_created
56        rax_tenant_id
57        rax_loaded
58
59Configuration:
60    rax.py can be configured using a rax.ini file or via environment
61    variables. The rax.ini file should live in the same directory along side
62    this script.
63
64    The section header for configuration values related to this
65    inventory plugin is [rax]
66
67    [rax]
68    creds_file = ~/.rackspace_cloud_credentials
69    regions = IAD,ORD,DFW
70    env = prod
71    meta_prefix = meta
72    access_network = public
73    access_ip_version = 4
74
75    Each of these configurations also has a corresponding environment variable.
76    An environment variable will override a configuration file value.
77
78    creds_file:
79        Environment Variable: RAX_CREDS_FILE
80
81        An optional configuration that points to a pyrax-compatible credentials
82        file.
83
84        If not supplied, rax.py will look for a credentials file
85        at ~/.rackspace_cloud_credentials.  It uses the Rackspace Python SDK,
86        and therefore requires a file formatted per the SDK's specifications.
87
88        https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md
89
90    regions:
91        Environment Variable: RAX_REGION
92
93        An optional environment variable to narrow inventory search
94        scope. If used, needs a value like ORD, DFW, SYD (a Rackspace
95        datacenter) and optionally accepts a comma-separated list.
96
97    environment:
98        Environment Variable: RAX_ENV
99
100        A configuration that will use an environment as configured in
101        ~/.pyrax.cfg, see
102        https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md
103
104    meta_prefix:
105        Environment Variable: RAX_META_PREFIX
106        Default: meta
107
108        A configuration that changes the prefix used for meta key/value groups.
109        For compatibility with ec2.py set to "tag"
110
111    access_network:
112        Environment Variable: RAX_ACCESS_NETWORK
113        Default: public
114
115        A configuration that will tell the inventory script to use a specific
116        server network to determine the ansible_ssh_host value. If no address
117        is found, ansible_ssh_host will not be set. Accepts a comma-separated
118        list of network names, the first found wins.
119
120    access_ip_version:
121        Environment Variable: RAX_ACCESS_IP_VERSION
122        Default: 4
123
124        A configuration related to "access_network" that will attempt to
125        determine the ansible_ssh_host value for either IPv4 or IPv6. If no
126        address is found, ansible_ssh_host will not be set.
127        Acceptable values are: 4 or 6. Values other than 4 or 6
128        will be ignored, and 4 will be used. Accepts a comma-separated list,
129        the first found wins.
130
131Examples:
132    List server instances
133    $ RAX_CREDS_FILE=~/.raxpub rax.py --list
134
135    List servers in ORD datacenter only
136    $ RAX_CREDS_FILE=~/.raxpub RAX_REGION=ORD rax.py --list
137
138    List servers in ORD and DFW datacenters
139    $ RAX_CREDS_FILE=~/.raxpub RAX_REGION=ORD,DFW rax.py --list
140
141    Get server details for server named "server.example.com"
142    $ RAX_CREDS_FILE=~/.raxpub rax.py --host server.example.com
143
144    Use the instance private IP to connect (instead of public IP)
145    $ RAX_CREDS_FILE=~/.raxpub RAX_ACCESS_NETWORK=private rax.py --list
146"""
147
148import os
149import re
150import sys
151import argparse
152import warnings
153import collections
154
155from ansible.module_utils.six import iteritems
156from ansible.module_utils.six.moves import configparser as ConfigParser
157
158import json
159
160try:
161    import pyrax
162    from pyrax.utils import slugify
163except ImportError:
164    sys.exit('pyrax is required for this module')
165
166from time import time
167
168from ansible.constants import get_config
169from ansible.module_utils.parsing.convert_bool import boolean
170from ansible.module_utils.six import text_type
171
172NON_CALLABLES = (text_type, str, bool, dict, int, list, type(None))
173
174
175def load_config_file():
176    p = ConfigParser.ConfigParser()
177    config_file = os.path.join(os.path.dirname(os.path.realpath(__file__)),
178                               'rax.ini')
179    try:
180        p.read(config_file)
181    except ConfigParser.Error:
182        return None
183    else:
184        return p
185
186
187def rax_slugify(value):
188    return 'rax_%s' % (re.sub(r'[^\w-]', '_', value).lower().lstrip('_'))
189
190
191def to_dict(obj):
192    instance = {}
193    for key in dir(obj):
194        value = getattr(obj, key)
195        if isinstance(value, NON_CALLABLES) and not key.startswith('_'):
196            key = rax_slugify(key)
197            instance[key] = value
198
199    return instance
200
201
202def host(regions, hostname):
203    hostvars = {}
204
205    for region in regions:
206        # Connect to the region
207        cs = pyrax.connect_to_cloudservers(region=region)
208        for server in cs.servers.list():
209            if server.name == hostname:
210                for key, value in to_dict(server).items():
211                    hostvars[key] = value
212
213                # And finally, add an IP address
214                hostvars['ansible_ssh_host'] = server.accessIPv4
215    print(json.dumps(hostvars, sort_keys=True, indent=4))
216
217
218def _list_into_cache(regions):
219    groups = collections.defaultdict(list)
220    hostvars = collections.defaultdict(dict)
221    images = {}
222    cbs_attachments = collections.defaultdict(dict)
223
224    prefix = get_config(p, 'rax', 'meta_prefix', 'RAX_META_PREFIX', 'meta')
225
226    try:
227        # Ansible 2.3+
228        networks = get_config(p, 'rax', 'access_network',
229                              'RAX_ACCESS_NETWORK', 'public', value_type='list')
230    except TypeError:
231        # Ansible 2.2.x and below
232        # pylint: disable=unexpected-keyword-arg
233        networks = get_config(p, 'rax', 'access_network',
234                              'RAX_ACCESS_NETWORK', 'public', islist=True)
235    try:
236        try:
237            # Ansible 2.3+
238            ip_versions = map(int, get_config(p, 'rax', 'access_ip_version',
239                                              'RAX_ACCESS_IP_VERSION', 4, value_type='list'))
240        except TypeError:
241            # Ansible 2.2.x and below
242            # pylint: disable=unexpected-keyword-arg
243            ip_versions = map(int, get_config(p, 'rax', 'access_ip_version',
244                                              'RAX_ACCESS_IP_VERSION', 4, islist=True))
245    except Exception:
246        ip_versions = [4]
247    else:
248        ip_versions = [v for v in ip_versions if v in [4, 6]]
249        if not ip_versions:
250            ip_versions = [4]
251
252    # Go through all the regions looking for servers
253    for region in regions:
254        # Connect to the region
255        cs = pyrax.connect_to_cloudservers(region=region)
256        if cs is None:
257            warnings.warn(
258                'Connecting to Rackspace region "%s" has caused Pyrax to '
259                'return None. Is this a valid region?' % region,
260                RuntimeWarning)
261            continue
262        for server in cs.servers.list():
263            # Create a group on region
264            groups[region].append(server.name)
265
266            # Check if group metadata key in servers' metadata
267            group = server.metadata.get('group')
268            if group:
269                groups[group].append(server.name)
270
271            for extra_group in server.metadata.get('groups', '').split(','):
272                if extra_group:
273                    groups[extra_group].append(server.name)
274
275            # Add host metadata
276            for key, value in to_dict(server).items():
277                hostvars[server.name][key] = value
278
279            hostvars[server.name]['rax_region'] = region
280
281            for key, value in iteritems(server.metadata):
282                groups['%s_%s_%s' % (prefix, key, value)].append(server.name)
283
284            groups['instance-%s' % server.id].append(server.name)
285            groups['flavor-%s' % server.flavor['id']].append(server.name)
286
287            # Handle boot from volume
288            if not server.image:
289                if not cbs_attachments[region]:
290                    cbs = pyrax.connect_to_cloud_blockstorage(region)
291                    for vol in cbs.list():
292                        if boolean(vol.bootable, strict=False):
293                            for attachment in vol.attachments:
294                                metadata = vol.volume_image_metadata
295                                server_id = attachment['server_id']
296                                cbs_attachments[region][server_id] = {
297                                    'id': metadata['image_id'],
298                                    'name': slugify(metadata['image_name'])
299                                }
300                image = cbs_attachments[region].get(server.id)
301                if image:
302                    server.image = {'id': image['id']}
303                    hostvars[server.name]['rax_image'] = server.image
304                    hostvars[server.name]['rax_boot_source'] = 'volume'
305                    images[image['id']] = image['name']
306            else:
307                hostvars[server.name]['rax_boot_source'] = 'local'
308
309            try:
310                imagegroup = 'image-%s' % images[server.image['id']]
311                groups[imagegroup].append(server.name)
312                groups['image-%s' % server.image['id']].append(server.name)
313            except KeyError:
314                try:
315                    image = cs.images.get(server.image['id'])
316                except cs.exceptions.NotFound:
317                    groups['image-%s' % server.image['id']].append(server.name)
318                else:
319                    images[image.id] = image.human_id
320                    groups['image-%s' % image.human_id].append(server.name)
321                    groups['image-%s' % server.image['id']].append(server.name)
322
323            # And finally, add an IP address
324            ansible_ssh_host = None
325            # use accessIPv[46] instead of looping address for 'public'
326            for network_name in networks:
327                if ansible_ssh_host:
328                    break
329                if network_name == 'public':
330                    for version_name in ip_versions:
331                        if ansible_ssh_host:
332                            break
333                        if version_name == 6 and server.accessIPv6:
334                            ansible_ssh_host = server.accessIPv6
335                        elif server.accessIPv4:
336                            ansible_ssh_host = server.accessIPv4
337                if not ansible_ssh_host:
338                    addresses = server.addresses.get(network_name, [])
339                    for address in addresses:
340                        for version_name in ip_versions:
341                            if ansible_ssh_host:
342                                break
343                            if address.get('version') == version_name:
344                                ansible_ssh_host = address.get('addr')
345                                break
346            if ansible_ssh_host:
347                hostvars[server.name]['ansible_ssh_host'] = ansible_ssh_host
348
349    if hostvars:
350        groups['_meta'] = {'hostvars': hostvars}
351
352    with open(get_cache_file_path(regions), 'w') as cache_file:
353        json.dump(groups, cache_file)
354
355
356def get_cache_file_path(regions):
357    regions_str = '.'.join([reg.strip().lower() for reg in regions])
358    ansible_tmp_path = os.path.join(os.path.expanduser("~"), '.ansible', 'tmp')
359    if not os.path.exists(ansible_tmp_path):
360        os.makedirs(ansible_tmp_path)
361    return os.path.join(ansible_tmp_path,
362                        'ansible-rax-%s-%s.cache' % (
363                            pyrax.identity.username, regions_str))
364
365
366def _list(regions, refresh_cache=True):
367    cache_max_age = int(get_config(p, 'rax', 'cache_max_age',
368                                   'RAX_CACHE_MAX_AGE', 600))
369
370    if (not os.path.exists(get_cache_file_path(regions)) or
371            refresh_cache or
372            (time() - os.stat(get_cache_file_path(regions))[-1]) > cache_max_age):
373        # Cache file doesn't exist or older than 10m or refresh cache requested
374        _list_into_cache(regions)
375
376    with open(get_cache_file_path(regions), 'r') as cache_file:
377        groups = json.load(cache_file)
378        print(json.dumps(groups, sort_keys=True, indent=4))
379
380
381def parse_args():
382    parser = argparse.ArgumentParser(description='Ansible Rackspace Cloud '
383                                                 'inventory module')
384    group = parser.add_mutually_exclusive_group(required=True)
385    group.add_argument('--list', action='store_true',
386                       help='List active servers')
387    group.add_argument('--host', help='List details about the specific host')
388    parser.add_argument('--refresh-cache', action='store_true', default=False,
389                        help=('Force refresh of cache, making API requests to'
390                              'RackSpace (default: False - use cache files)'))
391    return parser.parse_args()
392
393
394def setup():
395    default_creds_file = os.path.expanduser('~/.rackspace_cloud_credentials')
396
397    env = get_config(p, 'rax', 'environment', 'RAX_ENV', None)
398    if env:
399        pyrax.set_environment(env)
400
401    keyring_username = pyrax.get_setting('keyring_username')
402
403    # Attempt to grab credentials from environment first
404    creds_file = get_config(p, 'rax', 'creds_file',
405                            'RAX_CREDS_FILE', None)
406    if creds_file is not None:
407        creds_file = os.path.expanduser(creds_file)
408    else:
409        # But if that fails, use the default location of
410        # ~/.rackspace_cloud_credentials
411        if os.path.isfile(default_creds_file):
412            creds_file = default_creds_file
413        elif not keyring_username:
414            sys.exit('No value in environment variable %s and/or no '
415                     'credentials file at %s'
416                     % ('RAX_CREDS_FILE', default_creds_file))
417
418    identity_type = pyrax.get_setting('identity_type')
419    pyrax.set_setting('identity_type', identity_type or 'rackspace')
420
421    region = pyrax.get_setting('region')
422
423    try:
424        if keyring_username:
425            pyrax.keyring_auth(keyring_username, region=region)
426        else:
427            pyrax.set_credential_file(creds_file, region=region)
428    except Exception as e:
429        sys.exit("%s: %s" % (e, e.message))
430
431    regions = []
432    if region:
433        regions.append(region)
434    else:
435        try:
436            # Ansible 2.3+
437            region_list = get_config(p, 'rax', 'regions', 'RAX_REGION', 'all',
438                                     value_type='list')
439        except TypeError:
440            # Ansible 2.2.x and below
441            # pylint: disable=unexpected-keyword-arg
442            region_list = get_config(p, 'rax', 'regions', 'RAX_REGION', 'all',
443                                     islist=True)
444
445        for region in region_list:
446            region = region.strip().upper()
447            if region == 'ALL':
448                regions = pyrax.regions
449                break
450            elif region not in pyrax.regions:
451                sys.exit('Unsupported region %s' % region)
452            elif region not in regions:
453                regions.append(region)
454
455    return regions
456
457
458def main():
459    args = parse_args()
460    regions = setup()
461    if args.list:
462        _list(regions, refresh_cache=args.refresh_cache)
463    elif args.host:
464        host(regions, args.host)
465    sys.exit(0)
466
467
468p = load_config_file()
469if __name__ == '__main__':
470    main()
471