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