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