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