1#!/usr/bin/env python 2 3''' 4Packet.net external inventory script 5================================= 6 7Generates inventory that Ansible can understand by making API request to 8Packet.net using the Packet library. 9 10NOTE: This script assumes Ansible is being executed where the environment 11variable needed for Packet API Token already been set: 12 export PACKET_API_TOKEN=Bfse9F24SFtfs423Gsd3ifGsd43sSdfs 13 14This script also assumes there is a packet_net.ini file alongside it. To specify a 15different path to packet_net.ini, define the PACKET_NET_INI_PATH environment variable: 16 17 export PACKET_NET_INI_PATH=/path/to/my_packet_net.ini 18 19''' 20 21# (c) 2016, Peter Sankauskas 22# (c) 2017, Tomas Karasek 23# 24# This file is part of Ansible, 25# 26# Ansible is free software: you can redistribute it and/or modify 27# it under the terms of the GNU General Public License as published by 28# the Free Software Foundation, either version 3 of the License, or 29# (at your option) any later version. 30# 31# Ansible is distributed in the hope that it will be useful, 32# but WITHOUT ANY WARRANTY; without even the implied warranty of 33# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 34# GNU General Public License for more details. 35# 36# You should have received a copy of the GNU General Public License 37# along with Ansible. If not, see <http://www.gnu.org/licenses/>. 38 39###################################################################### 40 41import sys 42import os 43import argparse 44import re 45from time import time 46 47from ansible.module_utils import six 48from ansible.module_utils.six.moves import configparser 49 50try: 51 import packet 52except ImportError as e: 53 sys.exit("failed=True msg='`packet-python` library required for this script'") 54 55import traceback 56 57 58import json 59 60 61ini_section = 'packet' 62 63 64class PacketInventory(object): 65 66 def _empty_inventory(self): 67 return {"_meta": {"hostvars": {}}} 68 69 def __init__(self): 70 ''' Main execution path ''' 71 72 # Inventory grouped by device IDs, tags, security groups, regions, 73 # and availability zones 74 self.inventory = self._empty_inventory() 75 76 # Index of hostname (address) to device ID 77 self.index = {} 78 79 # Read settings and parse CLI arguments 80 self.parse_cli_args() 81 self.read_settings() 82 83 # Cache 84 if self.args.refresh_cache: 85 self.do_api_calls_update_cache() 86 elif not self.is_cache_valid(): 87 self.do_api_calls_update_cache() 88 89 # Data to print 90 if self.args.host: 91 data_to_print = self.get_host_info() 92 93 elif self.args.list: 94 # Display list of devices for inventory 95 if self.inventory == self._empty_inventory(): 96 data_to_print = self.get_inventory_from_cache() 97 else: 98 data_to_print = self.json_format_dict(self.inventory, True) 99 100 print(data_to_print) 101 102 def is_cache_valid(self): 103 ''' Determines if the cache files have expired, or if it is still valid ''' 104 105 if os.path.isfile(self.cache_path_cache): 106 mod_time = os.path.getmtime(self.cache_path_cache) 107 current_time = time() 108 if (mod_time + self.cache_max_age) > current_time: 109 if os.path.isfile(self.cache_path_index): 110 return True 111 112 return False 113 114 def read_settings(self): 115 ''' Reads the settings from the packet_net.ini file ''' 116 if six.PY3: 117 config = configparser.ConfigParser() 118 else: 119 config = configparser.SafeConfigParser() 120 121 _ini_path_raw = os.environ.get('PACKET_NET_INI_PATH') 122 123 if _ini_path_raw: 124 packet_ini_path = os.path.expanduser(os.path.expandvars(_ini_path_raw)) 125 else: 126 packet_ini_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'packet_net.ini') 127 config.read(packet_ini_path) 128 129 # items per page 130 self.items_per_page = 999 131 if config.has_option(ini_section, 'items_per_page'): 132 config.get(ini_section, 'items_per_page') 133 134 # Instance states to be gathered in inventory. Default is all of them. 135 packet_valid_device_states = [ 136 'active', 137 'inactive', 138 'queued', 139 'provisioning' 140 ] 141 self.packet_device_states = [] 142 if config.has_option(ini_section, 'device_states'): 143 for device_state in config.get(ini_section, 'device_states').split(','): 144 device_state = device_state.strip() 145 if device_state not in packet_valid_device_states: 146 continue 147 self.packet_device_states.append(device_state) 148 else: 149 self.packet_device_states = packet_valid_device_states 150 151 # Cache related 152 cache_dir = os.path.expanduser(config.get(ini_section, 'cache_path')) 153 if not os.path.exists(cache_dir): 154 os.makedirs(cache_dir) 155 156 self.cache_path_cache = cache_dir + "/ansible-packet.cache" 157 self.cache_path_index = cache_dir + "/ansible-packet.index" 158 self.cache_max_age = config.getint(ini_section, 'cache_max_age') 159 160 # Configure nested groups instead of flat namespace. 161 if config.has_option(ini_section, 'nested_groups'): 162 self.nested_groups = config.getboolean(ini_section, 'nested_groups') 163 else: 164 self.nested_groups = False 165 166 # Replace dash or not in group names 167 if config.has_option(ini_section, 'replace_dash_in_groups'): 168 self.replace_dash_in_groups = config.getboolean(ini_section, 'replace_dash_in_groups') 169 else: 170 self.replace_dash_in_groups = True 171 172 # Configure which groups should be created. 173 group_by_options = [ 174 'group_by_device_id', 175 'group_by_hostname', 176 'group_by_facility', 177 'group_by_project', 178 'group_by_operating_system', 179 'group_by_plan_type', 180 'group_by_tags', 181 'group_by_tag_none', 182 ] 183 for option in group_by_options: 184 if config.has_option(ini_section, option): 185 setattr(self, option, config.getboolean(ini_section, option)) 186 else: 187 setattr(self, option, True) 188 189 # Do we need to just include hosts that match a pattern? 190 try: 191 pattern_include = config.get(ini_section, 'pattern_include') 192 if pattern_include and len(pattern_include) > 0: 193 self.pattern_include = re.compile(pattern_include) 194 else: 195 self.pattern_include = None 196 except configparser.NoOptionError: 197 self.pattern_include = None 198 199 # Do we need to exclude hosts that match a pattern? 200 try: 201 pattern_exclude = config.get(ini_section, 'pattern_exclude') 202 if pattern_exclude and len(pattern_exclude) > 0: 203 self.pattern_exclude = re.compile(pattern_exclude) 204 else: 205 self.pattern_exclude = None 206 except configparser.NoOptionError: 207 self.pattern_exclude = None 208 209 # Projects 210 self.projects = [] 211 configProjects = config.get(ini_section, 'projects') 212 configProjects_exclude = config.get(ini_section, 'projects_exclude') 213 if (configProjects == 'all'): 214 for projectInfo in self.get_projects(): 215 if projectInfo.name not in configProjects_exclude: 216 self.projects.append(projectInfo.name) 217 else: 218 self.projects = configProjects.split(",") 219 220 def parse_cli_args(self): 221 ''' Command line argument processing ''' 222 223 parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Packet') 224 parser.add_argument('--list', action='store_true', default=True, 225 help='List Devices (default: True)') 226 parser.add_argument('--host', action='store', 227 help='Get all the variables about a specific device') 228 parser.add_argument('--refresh-cache', action='store_true', default=False, 229 help='Force refresh of cache by making API requests to Packet (default: False - use cache files)') 230 self.args = parser.parse_args() 231 232 def do_api_calls_update_cache(self): 233 ''' Do API calls to each region, and save data in cache files ''' 234 235 for projectInfo in self.get_projects(): 236 if projectInfo.name in self.projects: 237 self.get_devices_by_project(projectInfo) 238 239 self.write_to_cache(self.inventory, self.cache_path_cache) 240 self.write_to_cache(self.index, self.cache_path_index) 241 242 def connect(self): 243 ''' create connection to api server''' 244 token = os.environ.get('PACKET_API_TOKEN') 245 if token is None: 246 raise Exception("Error reading token from environment (PACKET_API_TOKEN)!") 247 manager = packet.Manager(auth_token=token) 248 return manager 249 250 def get_projects(self): 251 '''Makes a Packet API call to get the list of projects''' 252 try: 253 manager = self.connect() 254 projects = manager.list_projects() 255 return projects 256 except Exception as e: 257 traceback.print_exc() 258 self.fail_with_error(e, 'getting Packet projects') 259 260 def get_devices_by_project(self, project): 261 ''' Makes an Packet API call to the list of devices in a particular 262 project ''' 263 264 params = { 265 'per_page': self.items_per_page 266 } 267 268 try: 269 manager = self.connect() 270 devices = manager.list_devices(project_id=project.id, params=params) 271 272 for device in devices: 273 self.add_device(device, project) 274 275 except Exception as e: 276 traceback.print_exc() 277 self.fail_with_error(e, 'getting Packet devices') 278 279 def fail_with_error(self, err_msg, err_operation=None): 280 '''log an error to std err for ansible-playbook to consume and exit''' 281 if err_operation: 282 err_msg = 'ERROR: "{err_msg}", while: {err_operation}\n'.format( 283 err_msg=err_msg, err_operation=err_operation) 284 sys.stderr.write(err_msg) 285 sys.exit(1) 286 287 def get_device(self, device_id): 288 manager = self.connect() 289 290 device = manager.get_device(device_id) 291 return device 292 293 def add_device(self, device, project): 294 ''' Adds a device to the inventory and index, as long as it is 295 addressable ''' 296 297 # Only return devices with desired device states 298 if device.state not in self.packet_device_states: 299 return 300 301 # Select the best destination address. Only include management 302 # addresses as non-management (elastic) addresses need manual 303 # host configuration to be routable. 304 # See https://help.packet.net/article/54-elastic-ips. 305 dest = None 306 for ip_address in device.ip_addresses: 307 if ip_address['public'] is True and \ 308 ip_address['address_family'] == 4 and \ 309 ip_address['management'] is True: 310 dest = ip_address['address'] 311 312 if not dest: 313 # Skip devices we cannot address (e.g. private VPC subnet) 314 return 315 316 # if we only want to include hosts that match a pattern, skip those that don't 317 if self.pattern_include and not self.pattern_include.match(device.hostname): 318 return 319 320 # if we need to exclude hosts that match a pattern, skip those 321 if self.pattern_exclude and self.pattern_exclude.match(device.hostname): 322 return 323 324 # Add to index 325 self.index[dest] = [project.id, device.id] 326 327 # Inventory: Group by device ID (always a group of 1) 328 if self.group_by_device_id: 329 self.inventory[device.id] = [dest] 330 if self.nested_groups: 331 self.push_group(self.inventory, 'devices', device.id) 332 333 # Inventory: Group by device name (hopefully a group of 1) 334 if self.group_by_hostname: 335 self.push(self.inventory, device.hostname, dest) 336 if self.nested_groups: 337 self.push_group(self.inventory, 'hostnames', project.name) 338 339 # Inventory: Group by project 340 if self.group_by_project: 341 self.push(self.inventory, project.name, dest) 342 if self.nested_groups: 343 self.push_group(self.inventory, 'projects', project.name) 344 345 # Inventory: Group by facility 346 if self.group_by_facility: 347 self.push(self.inventory, device.facility['code'], dest) 348 if self.nested_groups: 349 if self.group_by_facility: 350 self.push_group(self.inventory, project.name, device.facility['code']) 351 352 # Inventory: Group by OS 353 if self.group_by_operating_system: 354 self.push(self.inventory, device.operating_system.slug, dest) 355 if self.nested_groups: 356 self.push_group(self.inventory, 'operating_systems', device.operating_system.slug) 357 358 # Inventory: Group by plan type 359 if self.group_by_plan_type: 360 self.push(self.inventory, device.plan['slug'], dest) 361 if self.nested_groups: 362 self.push_group(self.inventory, 'plans', device.plan['slug']) 363 364 # Inventory: Group by tag keys 365 if self.group_by_tags: 366 for k in device.tags: 367 key = self.to_safe("tag_" + k) 368 self.push(self.inventory, key, dest) 369 if self.nested_groups: 370 self.push_group(self.inventory, 'tags', self.to_safe("tag_" + k)) 371 372 # Global Tag: devices without tags 373 if self.group_by_tag_none and len(device.tags) == 0: 374 self.push(self.inventory, 'tag_none', dest) 375 if self.nested_groups: 376 self.push_group(self.inventory, 'tags', 'tag_none') 377 378 # Global Tag: tag all Packet devices 379 self.push(self.inventory, 'packet', dest) 380 381 self.inventory["_meta"]["hostvars"][dest] = self.get_host_info_dict_from_device(device) 382 383 def get_host_info_dict_from_device(self, device): 384 device_vars = {} 385 for key in vars(device): 386 value = getattr(device, key) 387 key = self.to_safe('packet_' + key) 388 389 # Handle complex types 390 if key == 'packet_state': 391 device_vars[key] = device.state or '' 392 elif key == 'packet_hostname': 393 device_vars[key] = value 394 elif isinstance(value, (int, bool)): 395 device_vars[key] = value 396 elif isinstance(value, six.string_types): 397 device_vars[key] = value.strip() 398 elif value is None: 399 device_vars[key] = '' 400 elif key == 'packet_facility': 401 device_vars[key] = value['code'] 402 elif key == 'packet_operating_system': 403 device_vars[key] = value.slug 404 elif key == 'packet_plan': 405 device_vars[key] = value['slug'] 406 elif key == 'packet_tags': 407 for k in value: 408 key = self.to_safe('packet_tag_' + k) 409 device_vars[key] = k 410 else: 411 pass 412 # print key 413 # print type(value) 414 # print value 415 416 return device_vars 417 418 def get_host_info(self): 419 ''' Get variables about a specific host ''' 420 421 if len(self.index) == 0: 422 # Need to load index from cache 423 self.load_index_from_cache() 424 425 if self.args.host not in self.index: 426 # try updating the cache 427 self.do_api_calls_update_cache() 428 if self.args.host not in self.index: 429 # host might not exist anymore 430 return self.json_format_dict({}, True) 431 432 (project_id, device_id) = self.index[self.args.host] 433 434 device = self.get_device(device_id) 435 return self.json_format_dict(self.get_host_info_dict_from_device(device), True) 436 437 def push(self, my_dict, key, element): 438 ''' Push an element onto an array that may not have been defined in 439 the dict ''' 440 group_info = my_dict.setdefault(key, []) 441 if isinstance(group_info, dict): 442 host_list = group_info.setdefault('hosts', []) 443 host_list.append(element) 444 else: 445 group_info.append(element) 446 447 def push_group(self, my_dict, key, element): 448 ''' Push a group as a child of another group. ''' 449 parent_group = my_dict.setdefault(key, {}) 450 if not isinstance(parent_group, dict): 451 parent_group = my_dict[key] = {'hosts': parent_group} 452 child_groups = parent_group.setdefault('children', []) 453 if element not in child_groups: 454 child_groups.append(element) 455 456 def get_inventory_from_cache(self): 457 ''' Reads the inventory from the cache file and returns it as a JSON 458 object ''' 459 460 cache = open(self.cache_path_cache, 'r') 461 json_inventory = cache.read() 462 return json_inventory 463 464 def load_index_from_cache(self): 465 ''' Reads the index from the cache file sets self.index ''' 466 467 cache = open(self.cache_path_index, 'r') 468 json_index = cache.read() 469 self.index = json.loads(json_index) 470 471 def write_to_cache(self, data, filename): 472 ''' Writes data in JSON format to a file ''' 473 474 json_data = self.json_format_dict(data, True) 475 cache = open(filename, 'w') 476 cache.write(json_data) 477 cache.close() 478 479 def uncammelize(self, key): 480 temp = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', key) 481 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', temp).lower() 482 483 def to_safe(self, word): 484 ''' Converts 'bad' characters in a string to underscores so they can be used as Ansible groups ''' 485 regex = r"[^A-Za-z0-9\_" 486 if not self.replace_dash_in_groups: 487 regex += r"\-" 488 return re.sub(regex + "]", "_", word) 489 490 def json_format_dict(self, data, pretty=False): 491 ''' Converts a dict to a JSON object and dumps it as a formatted 492 string ''' 493 494 if pretty: 495 return json.dumps(data, sort_keys=True, indent=2) 496 else: 497 return json.dumps(data) 498 499 500# Run the script 501PacketInventory() 502