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