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