1#!/usr/bin/env python 2# 3# (c) 2017 Apstra Inc, <community@apstra.com> 4# 5# This file is part of Ansible 6# 7# Ansible is free software: you can redistribute it and/or modify 8# it under the terms of the GNU General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# Ansible is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with Ansible. If not, see <http://www.gnu.org/licenses/>. 19# 20""" 21Apstra AOS external inventory script 22==================================== 23 24Ansible has a feature where instead of reading from /usr/local/etc/ansible/hosts 25as a text file, it can query external programs to obtain the list 26of hosts, groups the hosts are in, and even variables to assign to each host. 27 28To use this: 29 - copy this file over /usr/local/etc/ansible/hosts and chmod +x the file. 30 - Copy both files (.py and .ini) in your preferred directory 31 32More information about Ansible Dynamic Inventory here 33http://unix.stackexchange.com/questions/205479/in-ansible-dynamic-inventory-json-can-i-render-hostvars-based-on-the-hostname 34 352 modes are currently, supported: **device based** or **blueprint based**: 36 - For **Device based**, the list of device is taken from the global device list 37 the serial ID will be used as the inventory_hostname 38 - For **Blueprint based**, the list of device is taken from the given blueprint 39 the Node name will be used as the inventory_hostname 40 41Input parameters parameter can be provided using either with the ini file or by using Environment Variables: 42The following list of Environment Variables are supported: AOS_SERVER, AOS_PORT, AOS_USERNAME, AOS_PASSWORD, AOS_BLUEPRINT 43The config file takes precedence over the Environment Variables 44 45Tested with Apstra AOS 1.1 46 47This script has been inspired by the cobbler.py inventory. thanks 48 49Author: Damien Garros (@dgarros) 50Version: 0.2.0 51""" 52import json 53import os 54import re 55import sys 56 57try: 58 import argparse 59 HAS_ARGPARSE = True 60except ImportError: 61 HAS_ARGPARSE = False 62 63try: 64 from apstra.aosom.session import Session 65 HAS_AOS_PYEZ = True 66except ImportError: 67 HAS_AOS_PYEZ = False 68 69from ansible.module_utils.six.moves import configparser 70 71 72""" 73## 74Expected output format in Device mode 75{ 76 "Cumulus": { 77 "hosts": [ 78 "52540073956E", 79 "52540022211A" 80 ], 81 "vars": {} 82 }, 83 "EOS": { 84 "hosts": [ 85 "5254001CAFD8", 86 "525400DDDF72" 87 ], 88 "vars": {} 89 }, 90 "Generic Model": { 91 "hosts": [ 92 "525400E5486D" 93 ], 94 "vars": {} 95 }, 96 "Ubuntu GNU/Linux": { 97 "hosts": [ 98 "525400E5486D" 99 ], 100 "vars": {} 101 }, 102 "VX": { 103 "hosts": [ 104 "52540073956E", 105 "52540022211A" 106 ], 107 "vars": {} 108 }, 109 "_meta": { 110 "hostvars": { 111 "5254001CAFD8": { 112 "agent_start_time": "2017-02-03T00:49:16.000000Z", 113 "ansible_ssh_host": "172.20.52.6", 114 "aos_hcl_model": "Arista_vEOS", 115 "aos_server": "", 116 "aos_version": "AOS_1.1.1_OB.5", 117 "comm_state": "on", 118 "device_start_time": "2017-02-03T00:47:58.454480Z", 119 "domain_name": "", 120 "error_message": "", 121 "fqdn": "localhost", 122 "hostname": "localhost", 123 "hw_model": "vEOS", 124 "hw_version": "", 125 "is_acknowledged": false, 126 "mgmt_ifname": "Management1", 127 "mgmt_ipaddr": "172.20.52.6", 128 "mgmt_macaddr": "52:54:00:1C:AF:D8", 129 "os_arch": "x86_64", 130 "os_family": "EOS", 131 "os_version": "4.16.6M", 132 "os_version_info": { 133 "build": "6M", 134 "major": "4", 135 "minor": "16" 136 }, 137 "serial_number": "5254001CAFD8", 138 "state": "OOS-QUARANTINED", 139 "vendor": "Arista" 140 }, 141 "52540022211A": { 142 "agent_start_time": "2017-02-03T00:45:22.000000Z", 143 "ansible_ssh_host": "172.20.52.7", 144 "aos_hcl_model": "Cumulus_VX", 145 "aos_server": "172.20.52.3", 146 "aos_version": "AOS_1.1.1_OB.5", 147 "comm_state": "on", 148 "device_start_time": "2017-02-03T00:45:11.019189Z", 149 "domain_name": "", 150 "error_message": "", 151 "fqdn": "cumulus", 152 "hostname": "cumulus", 153 "hw_model": "VX", 154 "hw_version": "", 155 "is_acknowledged": false, 156 "mgmt_ifname": "eth0", 157 "mgmt_ipaddr": "172.20.52.7", 158 "mgmt_macaddr": "52:54:00:22:21:1a", 159 "os_arch": "x86_64", 160 "os_family": "Cumulus", 161 "os_version": "3.1.1", 162 "os_version_info": { 163 "build": "1", 164 "major": "3", 165 "minor": "1" 166 }, 167 "serial_number": "52540022211A", 168 "state": "OOS-QUARANTINED", 169 "vendor": "Cumulus" 170 }, 171 "52540073956E": { 172 "agent_start_time": "2017-02-03T00:45:19.000000Z", 173 "ansible_ssh_host": "172.20.52.8", 174 "aos_hcl_model": "Cumulus_VX", 175 "aos_server": "172.20.52.3", 176 "aos_version": "AOS_1.1.1_OB.5", 177 "comm_state": "on", 178 "device_start_time": "2017-02-03T00:45:11.030113Z", 179 "domain_name": "", 180 "error_message": "", 181 "fqdn": "cumulus", 182 "hostname": "cumulus", 183 "hw_model": "VX", 184 "hw_version": "", 185 "is_acknowledged": false, 186 "mgmt_ifname": "eth0", 187 "mgmt_ipaddr": "172.20.52.8", 188 "mgmt_macaddr": "52:54:00:73:95:6e", 189 "os_arch": "x86_64", 190 "os_family": "Cumulus", 191 "os_version": "3.1.1", 192 "os_version_info": { 193 "build": "1", 194 "major": "3", 195 "minor": "1" 196 }, 197 "serial_number": "52540073956E", 198 "state": "OOS-QUARANTINED", 199 "vendor": "Cumulus" 200 }, 201 "525400DDDF72": { 202 "agent_start_time": "2017-02-03T00:49:07.000000Z", 203 "ansible_ssh_host": "172.20.52.5", 204 "aos_hcl_model": "Arista_vEOS", 205 "aos_server": "", 206 "aos_version": "AOS_1.1.1_OB.5", 207 "comm_state": "on", 208 "device_start_time": "2017-02-03T00:47:46.929921Z", 209 "domain_name": "", 210 "error_message": "", 211 "fqdn": "localhost", 212 "hostname": "localhost", 213 "hw_model": "vEOS", 214 "hw_version": "", 215 "is_acknowledged": false, 216 "mgmt_ifname": "Management1", 217 "mgmt_ipaddr": "172.20.52.5", 218 "mgmt_macaddr": "52:54:00:DD:DF:72", 219 "os_arch": "x86_64", 220 "os_family": "EOS", 221 "os_version": "4.16.6M", 222 "os_version_info": { 223 "build": "6M", 224 "major": "4", 225 "minor": "16" 226 }, 227 "serial_number": "525400DDDF72", 228 "state": "OOS-QUARANTINED", 229 "vendor": "Arista" 230 }, 231 "525400E5486D": { 232 "agent_start_time": "2017-02-02T18:44:42.000000Z", 233 "ansible_ssh_host": "172.20.52.4", 234 "aos_hcl_model": "Generic_Server_1RU_1x10G", 235 "aos_server": "172.20.52.3", 236 "aos_version": "AOS_1.1.1_OB.5", 237 "comm_state": "on", 238 "device_start_time": "2017-02-02T21:11:25.188734Z", 239 "domain_name": "", 240 "error_message": "", 241 "fqdn": "localhost", 242 "hostname": "localhost", 243 "hw_model": "Generic Model", 244 "hw_version": "pc-i440fx-trusty", 245 "is_acknowledged": false, 246 "mgmt_ifname": "eth0", 247 "mgmt_ipaddr": "172.20.52.4", 248 "mgmt_macaddr": "52:54:00:e5:48:6d", 249 "os_arch": "x86_64", 250 "os_family": "Ubuntu GNU/Linux", 251 "os_version": "14.04 LTS", 252 "os_version_info": { 253 "build": "", 254 "major": "14", 255 "minor": "04" 256 }, 257 "serial_number": "525400E5486D", 258 "state": "OOS-QUARANTINED", 259 "vendor": "Generic Manufacturer" 260 } 261 } 262 }, 263 "all": { 264 "hosts": [ 265 "5254001CAFD8", 266 "52540073956E", 267 "525400DDDF72", 268 "525400E5486D", 269 "52540022211A" 270 ], 271 "vars": {} 272 }, 273 "vEOS": { 274 "hosts": [ 275 "5254001CAFD8", 276 "525400DDDF72" 277 ], 278 "vars": {} 279 } 280} 281""" 282 283 284def fail(msg): 285 sys.stderr.write("%s\n" % msg) 286 sys.exit(1) 287 288 289class AosInventory(object): 290 291 def __init__(self): 292 293 """ Main execution path """ 294 295 if not HAS_AOS_PYEZ: 296 raise Exception('aos-pyez is not installed. Please see details here: https://github.com/Apstra/aos-pyez') 297 if not HAS_ARGPARSE: 298 raise Exception('argparse is not installed. Please install the argparse library or upgrade to python-2.7') 299 300 # Initialize inventory 301 self.inventory = dict() # A list of groups and the hosts in that group 302 self.inventory['_meta'] = dict() 303 self.inventory['_meta']['hostvars'] = dict() 304 305 # Read settings and parse CLI arguments 306 self.read_settings() 307 self.parse_cli_args() 308 309 # ---------------------------------------------------- 310 # Open session to AOS 311 # ---------------------------------------------------- 312 aos = Session(server=self.aos_server, 313 port=self.aos_server_port, 314 user=self.aos_username, 315 passwd=self.aos_password) 316 317 aos.login() 318 319 # Save session information in variables of group all 320 self.add_var_to_group('all', 'aos_session', aos.session) 321 322 # Add the AOS server itself in the inventory 323 self.add_host_to_group("all", 'aos') 324 self.add_var_to_host("aos", "ansible_ssh_host", self.aos_server) 325 self.add_var_to_host("aos", "ansible_ssh_pass", self.aos_password) 326 self.add_var_to_host("aos", "ansible_ssh_user", self.aos_username) 327 328 # ---------------------------------------------------- 329 # Build the inventory 330 # 2 modes are supported: device based or blueprint based 331 # - For device based, the list of device is taken from the global device list 332 # the serial ID will be used as the inventory_hostname 333 # - For Blueprint based, the list of device is taken from the given blueprint 334 # the Node name will be used as the inventory_hostname 335 # ---------------------------------------------------- 336 if self.aos_blueprint: 337 338 bp = aos.Blueprints[self.aos_blueprint] 339 if bp.exists is False: 340 fail("Unable to find the Blueprint: %s" % self.aos_blueprint) 341 342 for dev_name, dev_id in bp.params['devices'].value.items(): 343 344 self.add_host_to_group('all', dev_name) 345 device = aos.Devices.find(uid=dev_id) 346 347 if 'facts' in device.value.keys(): 348 self.add_device_facts_to_var(dev_name, device) 349 350 # Define admin State and Status 351 if 'user_config' in device.value.keys(): 352 if 'admin_state' in device.value['user_config'].keys(): 353 self.add_var_to_host(dev_name, 'admin_state', device.value['user_config']['admin_state']) 354 355 self.add_device_status_to_var(dev_name, device) 356 357 # Go over the contents data structure 358 for node in bp.contents['system']['nodes']: 359 if node['display_name'] == dev_name: 360 self.add_host_to_group(node['role'], dev_name) 361 362 # Check for additional attribute to import 363 attributes_to_import = [ 364 'loopback_ip', 365 'asn', 366 'role', 367 'position', 368 ] 369 for attr in attributes_to_import: 370 if attr in node.keys(): 371 self.add_var_to_host(dev_name, attr, node[attr]) 372 373 # if blueprint_interface is enabled in the configuration 374 # Collect links information 375 if self.aos_blueprint_int: 376 interfaces = dict() 377 378 for link in bp.contents['system']['links']: 379 # each link has 2 sides [0,1], and it's unknown which one match this device 380 # at first we assume, first side match(0) and peer is (1) 381 peer_id = 1 382 383 for side in link['endpoints']: 384 if side['display_name'] == dev_name: 385 386 # import local information first 387 int_name = side['interface'] 388 389 # init dict 390 interfaces[int_name] = dict() 391 if 'ip' in side.keys(): 392 interfaces[int_name]['ip'] = side['ip'] 393 394 if 'interface' in side.keys(): 395 interfaces[int_name]['name'] = side['interface'] 396 397 if 'display_name' in link['endpoints'][peer_id].keys(): 398 interfaces[int_name]['peer'] = link['endpoints'][peer_id]['display_name'] 399 400 if 'ip' in link['endpoints'][peer_id].keys(): 401 interfaces[int_name]['peer_ip'] = link['endpoints'][peer_id]['ip'] 402 403 if 'type' in link['endpoints'][peer_id].keys(): 404 interfaces[int_name]['peer_type'] = link['endpoints'][peer_id]['type'] 405 406 else: 407 # if we haven't match the first time, prepare the peer_id 408 # for the second loop iteration 409 peer_id = 0 410 411 self.add_var_to_host(dev_name, 'interfaces', interfaces) 412 413 else: 414 for device in aos.Devices: 415 # If not reacheable, create by key and 416 # If reacheable, create by hostname 417 418 self.add_host_to_group('all', device.name) 419 420 # populate information for this host 421 self.add_device_status_to_var(device.name, device) 422 423 if 'user_config' in device.value.keys(): 424 for key, value in device.value['user_config'].items(): 425 self.add_var_to_host(device.name, key, value) 426 427 # Based on device status online|offline, collect facts as well 428 if device.value['status']['comm_state'] == 'on': 429 430 if 'facts' in device.value.keys(): 431 self.add_device_facts_to_var(device.name, device) 432 433 # Check if device is associated with a blueprint 434 # if it's create a new group 435 if 'blueprint_active' in device.value['status'].keys(): 436 if 'blueprint_id' in device.value['status'].keys(): 437 bp = aos.Blueprints.find(uid=device.value['status']['blueprint_id']) 438 439 if bp: 440 self.add_host_to_group(bp.name, device.name) 441 442 # ---------------------------------------------------- 443 # Convert the inventory and return a JSON String 444 # ---------------------------------------------------- 445 data_to_print = "" 446 data_to_print += self.json_format_dict(self.inventory, True) 447 448 print(data_to_print) 449 450 def read_settings(self): 451 """ Reads the settings from the apstra_aos.ini file """ 452 453 config = configparser.ConfigParser() 454 config.read(os.path.dirname(os.path.realpath(__file__)) + '/apstra_aos.ini') 455 456 # Default Values 457 self.aos_blueprint = False 458 self.aos_blueprint_int = True 459 self.aos_username = 'admin' 460 self.aos_password = 'admin' 461 self.aos_server_port = 8888 462 463 # Try to reach all parameters from File, if not available try from ENV 464 try: 465 self.aos_server = config.get('aos', 'aos_server') 466 except Exception: 467 if 'AOS_SERVER' in os.environ.keys(): 468 self.aos_server = os.environ['AOS_SERVER'] 469 470 try: 471 self.aos_server_port = config.get('aos', 'port') 472 except Exception: 473 if 'AOS_PORT' in os.environ.keys(): 474 self.aos_server_port = os.environ['AOS_PORT'] 475 476 try: 477 self.aos_username = config.get('aos', 'username') 478 except Exception: 479 if 'AOS_USERNAME' in os.environ.keys(): 480 self.aos_username = os.environ['AOS_USERNAME'] 481 482 try: 483 self.aos_password = config.get('aos', 'password') 484 except Exception: 485 if 'AOS_PASSWORD' in os.environ.keys(): 486 self.aos_password = os.environ['AOS_PASSWORD'] 487 488 try: 489 self.aos_blueprint = config.get('aos', 'blueprint') 490 except Exception: 491 if 'AOS_BLUEPRINT' in os.environ.keys(): 492 self.aos_blueprint = os.environ['AOS_BLUEPRINT'] 493 494 try: 495 if config.get('aos', 'blueprint_interface') in ['false', 'no']: 496 self.aos_blueprint_int = False 497 except Exception: 498 pass 499 500 def parse_cli_args(self): 501 """ Command line argument processing """ 502 503 parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Apstra AOS') 504 parser.add_argument('--list', action='store_true', default=True, help='List instances (default: True)') 505 parser.add_argument('--host', action='store', help='Get all the variables about a specific instance') 506 self.args = parser.parse_args() 507 508 def json_format_dict(self, data, pretty=False): 509 """ Converts a dict to a JSON object and dumps it as a formatted string """ 510 511 if pretty: 512 return json.dumps(data, sort_keys=True, indent=2) 513 else: 514 return json.dumps(data) 515 516 def add_host_to_group(self, group, host): 517 518 # Cleanup group name first 519 clean_group = self.cleanup_group_name(group) 520 521 # Check if the group exist, if not initialize it 522 if clean_group not in self.inventory.keys(): 523 self.inventory[clean_group] = {} 524 self.inventory[clean_group]['hosts'] = [] 525 self.inventory[clean_group]['vars'] = {} 526 527 self.inventory[clean_group]['hosts'].append(host) 528 529 def add_var_to_host(self, host, var, value): 530 531 # Check if the host exist, if not initialize it 532 if host not in self.inventory['_meta']['hostvars'].keys(): 533 self.inventory['_meta']['hostvars'][host] = {} 534 535 self.inventory['_meta']['hostvars'][host][var] = value 536 537 def add_var_to_group(self, group, var, value): 538 539 # Cleanup group name first 540 clean_group = self.cleanup_group_name(group) 541 542 # Check if the group exist, if not initialize it 543 if clean_group not in self.inventory.keys(): 544 self.inventory[clean_group] = {} 545 self.inventory[clean_group]['hosts'] = [] 546 self.inventory[clean_group]['vars'] = {} 547 548 self.inventory[clean_group]['vars'][var] = value 549 550 def add_device_facts_to_var(self, device_name, device): 551 552 # Populate variables for this host 553 self.add_var_to_host(device_name, 554 'ansible_ssh_host', 555 device.value['facts']['mgmt_ipaddr']) 556 557 self.add_var_to_host(device_name, 'id', device.id) 558 559 # self.add_host_to_group('all', device.name) 560 for key, value in device.value['facts'].items(): 561 self.add_var_to_host(device_name, key, value) 562 563 if key == 'os_family': 564 self.add_host_to_group(value, device_name) 565 elif key == 'hw_model': 566 self.add_host_to_group(value, device_name) 567 568 def cleanup_group_name(self, group_name): 569 """ 570 Clean up group name by : 571 - Replacing all non-alphanumeric caracter by underscore 572 - Converting to lowercase 573 """ 574 575 rx = re.compile(r'\W+') 576 clean_group = rx.sub('_', group_name).lower() 577 578 return clean_group 579 580 def add_device_status_to_var(self, device_name, device): 581 582 if 'status' in device.value.keys(): 583 for key, value in device.value['status'].items(): 584 self.add_var_to_host(device.name, key, value) 585 586 587# Run the script 588if __name__ == '__main__': 589 AosInventory() 590