1#!/usr/bin/env python 2 3''' 4nsot 5==== 6 7Ansible Dynamic Inventory to pull hosts from NSoT, a flexible CMDB by Dropbox 8 9Features 10-------- 11 12* Define host groups in form of NSoT device attribute criteria 13 14* All parameters defined by the spec as of 2015-09-05 are supported. 15 16 + ``--list``: Returns JSON hash of host groups -> hosts and top-level 17 ``_meta`` -> ``hostvars`` which correspond to all device attributes. 18 19 Group vars can be specified in the YAML configuration, noted below. 20 21 + ``--host <hostname>``: Returns JSON hash where every item is a device 22 attribute. 23 24* In addition to all attributes assigned to resource being returned, script 25 will also append ``site_id`` and ``id`` as facts to utilize. 26 27 28Configuration 29------------ 30 31Since it'd be annoying and failure prone to guess where you're configuration 32file is, use ``NSOT_INVENTORY_CONFIG`` to specify the path to it. 33 34This file should adhere to the YAML spec. All top-level variable must be 35desired Ansible group-name hashed with single 'query' item to define the NSoT 36attribute query. 37 38Queries follow the normal NSoT query syntax, `shown here`_ 39 40.. _shown here: https://github.com/dropbox/pynsot#set-queries 41 42.. code:: yaml 43 44 routers: 45 query: 'deviceType=ROUTER' 46 vars: 47 a: b 48 c: d 49 50 juniper_fw: 51 query: 'deviceType=FIREWALL manufacturer=JUNIPER' 52 53 not_f10: 54 query: '-manufacturer=FORCE10' 55 56The inventory will automatically use your ``.pynsotrc`` like normal pynsot from 57cli would, so make sure that's configured appropriately. 58 59.. note:: 60 61 Attributes I'm showing above are influenced from ones that the Trigger 62 project likes. As is the spirit of NSoT, use whichever attributes work best 63 for your workflow. 64 65If config file is blank or absent, the following default groups will be 66created: 67 68* ``routers``: deviceType=ROUTER 69* ``switches``: deviceType=SWITCH 70* ``firewalls``: deviceType=FIREWALL 71 72These are likely not useful for everyone so please use the configuration. :) 73 74.. note:: 75 76 By default, resources will only be returned for what your default 77 site is set for in your ``~/.pynsotrc``. 78 79 If you want to specify, add an extra key under the group for ``site: n``. 80 81Output Examples 82--------------- 83 84Here are some examples shown from just calling the command directly:: 85 86 $ NSOT_INVENTORY_CONFIG=$PWD/test.yaml ansible_nsot --list | jq '.' 87 { 88 "routers": { 89 "hosts": [ 90 "test1.example.com" 91 ], 92 "vars": { 93 "cool_level": "very", 94 "group": "routers" 95 } 96 }, 97 "firewalls": { 98 "hosts": [ 99 "test2.example.com" 100 ], 101 "vars": { 102 "cool_level": "enough", 103 "group": "firewalls" 104 } 105 }, 106 "_meta": { 107 "hostvars": { 108 "test2.example.com": { 109 "make": "SRX", 110 "site_id": 1, 111 "id": 108 112 }, 113 "test1.example.com": { 114 "make": "MX80", 115 "site_id": 1, 116 "id": 107 117 } 118 } 119 }, 120 "rtr_and_fw": { 121 "hosts": [ 122 "test1.example.com", 123 "test2.example.com" 124 ], 125 "vars": {} 126 } 127 } 128 129 130 $ NSOT_INVENTORY_CONFIG=$PWD/test.yaml ansible_nsot --host test1 | jq '.' 131 { 132 "make": "MX80", 133 "site_id": 1, 134 "id": 107 135 } 136 137''' 138 139from __future__ import print_function 140import sys 141import os 142import pkg_resources 143import argparse 144import json 145import yaml 146from textwrap import dedent 147from pynsot.client import get_api_client 148from pynsot.app import HttpServerError 149from click.exceptions import UsageError 150 151from ansible.module_utils.six import string_types 152 153 154def warning(*objs): 155 print("WARNING: ", *objs, file=sys.stderr) 156 157 158class NSoTInventory(object): 159 '''NSoT Client object for gather inventory''' 160 161 def __init__(self): 162 self.config = dict() 163 config_env = os.environ.get('NSOT_INVENTORY_CONFIG') 164 if config_env: 165 try: 166 config_file = os.path.abspath(config_env) 167 except IOError: # If file non-existent, use default config 168 self._config_default() 169 except Exception as e: 170 sys.exit('%s\n' % e) 171 172 with open(config_file) as f: 173 try: 174 self.config.update(yaml.safe_load(f)) 175 except TypeError: # If empty file, use default config 176 warning('Empty config file') 177 self._config_default() 178 except Exception as e: 179 sys.exit('%s\n' % e) 180 else: # Use defaults if env var missing 181 self._config_default() 182 self.groups = self.config.keys() 183 self.client = get_api_client() 184 self._meta = {'hostvars': dict()} 185 186 def _config_default(self): 187 default_yaml = ''' 188 --- 189 routers: 190 query: deviceType=ROUTER 191 switches: 192 query: deviceType=SWITCH 193 firewalls: 194 query: deviceType=FIREWALL 195 ''' 196 self.config = yaml.safe_load(dedent(default_yaml)) 197 198 def do_list(self): 199 '''Direct callback for when ``--list`` is provided 200 201 Relies on the configuration generated from init to run 202 _inventory_group() 203 ''' 204 inventory = dict() 205 for group, contents in self.config.items(): 206 group_response = self._inventory_group(group, contents) 207 inventory.update(group_response) 208 inventory.update({'_meta': self._meta}) 209 return json.dumps(inventory) 210 211 def do_host(self, host): 212 return json.dumps(self._hostvars(host)) 213 214 def _hostvars(self, host): 215 '''Return dictionary of all device attributes 216 217 Depending on number of devices in NSoT, could be rather slow since this 218 has to request every device resource to filter through 219 ''' 220 device = [i for i in self.client.devices.get() 221 if host in i['hostname']][0] 222 attributes = device['attributes'] 223 attributes.update({'site_id': device['site_id'], 'id': device['id']}) 224 return attributes 225 226 def _inventory_group(self, group, contents): 227 '''Takes a group and returns inventory for it as dict 228 229 :param group: Group name 230 :type group: str 231 :param contents: The contents of the group's YAML config 232 :type contents: dict 233 234 contents param should look like:: 235 236 { 237 'query': 'xx', 238 'vars': 239 'a': 'b' 240 } 241 242 Will return something like:: 243 244 { group: { 245 hosts: [], 246 vars: {}, 247 } 248 ''' 249 query = contents.get('query') 250 hostvars = contents.get('vars', dict()) 251 site = contents.get('site', dict()) 252 obj = {group: dict()} 253 obj[group]['hosts'] = [] 254 obj[group]['vars'] = hostvars 255 try: 256 assert isinstance(query, string_types) 257 except Exception: 258 sys.exit('ERR: Group queries must be a single string\n' 259 ' Group: %s\n' 260 ' Query: %s\n' % (group, query) 261 ) 262 try: 263 if site: 264 site = self.client.sites(site) 265 devices = site.devices.query.get(query=query) 266 else: 267 devices = self.client.devices.query.get(query=query) 268 except HttpServerError as e: 269 if '500' in str(e.response): 270 _site = 'Correct site id?' 271 _attr = 'Queried attributes actually exist?' 272 questions = _site + '\n' + _attr 273 sys.exit('ERR: 500 from server.\n%s' % questions) 274 else: 275 raise 276 except UsageError: 277 sys.exit('ERR: Could not connect to server. Running?') 278 279 # Would do a list comprehension here, but would like to save code/time 280 # and also acquire attributes in this step 281 for host in devices: 282 # Iterate through each device that matches query, assign hostname 283 # to the group's hosts array and then use this single iteration as 284 # a chance to update self._meta which will be used in the final 285 # return 286 hostname = host['hostname'] 287 obj[group]['hosts'].append(hostname) 288 attributes = host['attributes'] 289 attributes.update({'site_id': host['site_id'], 'id': host['id']}) 290 self._meta['hostvars'].update({hostname: attributes}) 291 292 return obj 293 294 295def parse_args(): 296 desc = __doc__.splitlines()[4] # Just to avoid being redundant 297 298 # Establish parser with options and error out if no action provided 299 parser = argparse.ArgumentParser( 300 description=desc, 301 conflict_handler='resolve', 302 ) 303 304 # Arguments 305 # 306 # Currently accepting (--list | -l) and (--host | -h) 307 # These must not be allowed together 308 parser.add_argument( 309 '--list', '-l', 310 help='Print JSON object containing hosts to STDOUT', 311 action='store_true', 312 dest='list_', # Avoiding syntax highlighting for list 313 ) 314 315 parser.add_argument( 316 '--host', '-h', 317 help='Print JSON object containing hostvars for <host>', 318 action='store', 319 ) 320 args = parser.parse_args() 321 322 if not args.list_ and not args.host: # Require at least one option 323 parser.exit(status=1, message='No action requested') 324 325 if args.list_ and args.host: # Do not allow multiple options 326 parser.exit(status=1, message='Too many actions requested') 327 328 return args 329 330 331def main(): 332 '''Set up argument handling and callback routing''' 333 args = parse_args() 334 client = NSoTInventory() 335 336 # Callback condition 337 if args.list_: 338 print(client.do_list()) 339 elif args.host: 340 print(client.do_host(args.host)) 341 342 343if __name__ == '__main__': 344 main() 345