network/exos: add exos_facts module (#43210)

Add exos_facts module. Known limitations at this time include:
   - Interface MTU is not reported.
   - Only primary interface IP is reported.

Add basic unit tests for the exos_facts module.

An EXOS CLI prompt can be prefixed with '! ' (shutting down), '* '
(running configuration does not match saved configuration), and
can include various status tokens within parentheses after these
prefixes. Update prompt regex to accept valid CLI prompts.
This commit is contained in:
Lance Richardson 2018-08-08 10:02:18 -04:00 committed by Ricardo Carrillo Cruz
parent e24c036057
commit bd4d68c785
10 changed files with 697 additions and 1 deletions

View file

@ -0,0 +1,450 @@
#!/usr/bin/python
#
# (c) 2018 Extreme Networks Inc.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
#
from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}
DOCUMENTATION = """
---
module: exos_facts
version_added: "2.7"
author: "Lance Richardson (@hlrichardson)"
short_description: Collect facts from devices running Extreme EXOS
description:
- Collects a base set of device facts from a remote device that
is running EXOS. This module prepends all of the base network
fact keys with C(ansible_net_<fact>). The facts module will
always collect a base set of facts from the device and can
enable or disable collection of additional facts.
notes:
- Tested against EXOS 22.5.1.7
options:
gather_subset:
description:
- When supplied, this argument will restrict the facts collected
to a given subset. Possible values for this argument include
all, hardware, config, and interfaces. Can specify a list of
values to include a larger subset. Values can also be used
with an initial C(M(!)) to specify that a specific subset should
not be collected.
required: false
default: ['!config']
"""
EXAMPLES = """
- name: collect all facts from the device
exos_facts:
gather_subset: all
- name: collect only the config and default facts
exos_facts:
gather_subset: config
- name: do not collect hardware facts
exos_facts:
gather_subset: "!hardware"
"""
RETURN = """
ansible_net_gather_subset:
description: The list of fact subsets collected from the device
returned: always
type: list
# default
ansible_net_model:
description: The model name returned from the device
returned: always
type: str
ansible_net_serialnum:
description: The serial number of the remote device
returned: always
type: str
ansible_net_version:
description: The operating system version running on the remote device
returned: always
type: str
ansible_net_hostname:
description: The configured hostname of the device
returned: always
type: string
# hardware
ansible_net_memfree_mb:
description: The available free memory on the remote device in Mb
returned: when hardware is configured
type: int
ansible_net_memtotal_mb:
description: The total memory on the remote device in Mb
returned: when hardware is configured
type: int
# config
ansible_net_config:
description: The current active config from the device
returned: when config is configured
type: str
# interfaces
ansible_net_all_ipv4_addresses:
description: All IPv4 addresses configured on the device
returned: when interfaces is configured
type: list
ansible_net_all_ipv6_addresses:
description: All Primary IPv6 addresses configured on the device
returned: when interfaces is configured
type: list
ansible_net_interfaces:
description: A hash of all interfaces running on the system
returned: when interfaces is configured
type: dict
ansible_net_neighbors:
description: The list of LLDP neighbors from the remote device
returned: when interfaces is configured
type: dict
"""
import re
import json
from ansible.module_utils.network.exos.exos import run_commands
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six import iteritems
class FactsBase(object):
COMMANDS = list()
def __init__(self, module):
self.module = module
self.facts = dict()
self.responses = None
def populate(self):
self.responses = run_commands(self.module, self.COMMANDS)
def run(self, cmd):
return run_commands(self.module, cmd)
class Default(FactsBase):
COMMANDS = [
'show version',
'show switch'
]
def populate(self):
super(Default, self).populate()
data = self.responses[0]
if data:
self.facts['version'] = self.parse_version(data)
self.facts['serialnum'] = self.parse_serialnum(data)
data = self.responses[1]
if data:
self.facts['model'] = self.parse_model(data)
self.facts['hostname'] = self.parse_hostname(data)
def parse_version(self, data):
match = re.search(r'Image\s+: ExtremeXOS version (\S+)', data)
if match:
return match.group(1)
def parse_model(self, data):
match = re.search(r'System Type:\s+(.*$)', data, re.M)
if match:
return match.group(1)
def parse_hostname(self, data):
match = re.search(r'SysName:\s+(\S+)', data, re.M)
if match:
return match.group(1)
def parse_serialnum(self, data):
match = re.search(r'Switch\s+: \S+ (\S+)', data, re.M)
if match:
return match.group(1)
# For stack, return serial number of the first switch in the stack.
match = re.search(r'Slot-\d+\s+: \S+ (\S+)', data, re.M)
if match:
return match.group(1)
# Handle unique formatting for VM
match = re.search(r'Switch\s+: PN:\S+\s+SN:(\S+)', data, re.M)
if match:
return match.group(1)
class Hardware(FactsBase):
COMMANDS = [
'show memory'
]
def populate(self):
super(Hardware, self).populate()
data = self.responses[0]
if data:
self.facts['memtotal_mb'] = int(round(int(self.parse_memtotal(data)) / 1024, 0))
self.facts['memfree_mb'] = int(round(int(self.parse_memfree(data)) / 1024, 0))
def parse_memtotal(self, data):
match = re.search(r' Total DRAM \(KB\): (\d+)', data, re.M)
if match:
return match.group(1)
# Handle unique formatting for VM
match = re.search(r' Total \s+\(KB\): (\d+)', data, re.M)
if match:
return match.group(1)
def parse_memfree(self, data):
match = re.search(r' Free\s+\(KB\): (\d+)', data, re.M)
if match:
return match.group(1)
class Config(FactsBase):
COMMANDS = ['show configuration detail']
def populate(self):
super(Config, self).populate()
data = self.responses[0]
if data:
self.facts['config'] = data
class Interfaces(FactsBase):
COMMANDS = [
'show switch',
'run script cli2json.py show port config',
'run script cli2json.py show port description',
'run script cli2json.py show vlan detail',
'run script cli2json.py show lldp neighbors'
]
def populate(self):
super(Interfaces, self).populate()
self.facts['all_ipv4_addresses'] = list()
self.facts['all_ipv6_addresses'] = list()
data = self.responses[0]
if data:
sysmac = self.parse_sysmac(data)
data = json.loads(self.responses[1])
if data:
self.facts['interfaces'] = self.populate_interfaces(data, sysmac)
data = json.loads(self.responses[2])
if data:
self.populate_interface_descriptions(data)
data = json.loads(self.responses[3])
if data:
self.populate_vlan_interfaces(data, sysmac)
data = json.loads(self.responses[4])
if data:
self.facts['neighbors'] = self.parse_neighbors(data)
def parse_sysmac(self, data):
match = re.search(r'System MAC:\s+(\S+)', data, re.M)
if match:
return match.group(1)
def populate_interfaces(self, interfaces, sysmac):
facts = dict()
for elem in interfaces:
intf = dict()
if 'show_ports_config' not in elem:
continue
key = str(elem['show_ports_config']['port'])
if elem['show_ports_config']['linkState'] == 2:
# Link state is "not present", don't include
continue
intf['type'] = 'Ethernet'
intf['macaddress'] = sysmac
intf['bandwidth_configured'] = str(elem['show_ports_config']['speedCfg'])
intf['bandwidth'] = str(elem['show_ports_config']['speedActual'])
intf['duplex_configured'] = elem['show_ports_config']['duplexCfg']
intf['duplex'] = elem['show_ports_config']['duplexActual']
if elem['show_ports_config']['linkState'] == 1:
intf['lineprotocol'] = 'up'
else:
intf['lineprotocol'] = 'down'
if elem['show_ports_config']['portState'] == 1:
intf['operstatus'] = 'up'
else:
intf['operstatus'] = 'admin down'
facts[key] = intf
return facts
def populate_interface_descriptions(self, data):
facts = dict()
for elem in data:
if 'show_ports_description' not in elem:
continue
key = str(elem['show_ports_description']['port'])
if 'descriptionString' in elem['show_ports_description']:
desc = elem['show_ports_description']['descriptionString']
self.facts['interfaces'][key]['description'] = desc
def populate_vlan_interfaces(self, data, sysmac):
for elem in data:
if 'vlanProc' in elem:
key = elem['vlanProc']['name1']
if key not in self.facts['interfaces']:
intf = dict()
intf['type'] = 'VLAN'
intf['macaddress'] = sysmac
self.facts['interfaces'][key] = intf
if elem['vlanProc']['ipAddress'] != '0.0.0.0':
self.facts['interfaces'][key]['ipv4'] = list()
addr = elem['vlanProc']['ipAddress']
subnet = elem['vlanProc']['maskForDisplay']
ipv4 = dict(address=addr, subnet=subnet)
self.add_ip_address(addr, 'ipv4')
self.facts['interfaces'][key]['ipv4'].append(ipv4)
if 'rtifIpv6Address' in elem:
key = elem['rtifIpv6Address']['rtifName']
if key not in self.facts['interfaces']:
intf = dict()
intf['type'] = 'VLAN'
intf['macaddress'] = sysmac
self.facts['interfaces'][key] = intf
self.facts['interfaces'][key]['ipv6'] = list()
addr, subnet = elem['rtifIpv6Address']['ipv6_address_mask'].split('/')
ipv6 = dict(address=addr, subnet=subnet)
self.add_ip_address(addr, 'ipv6')
self.facts['interfaces'][key]['ipv6'].append(ipv6)
def add_ip_address(self, address, family):
if family == 'ipv4':
if address not in self.facts['all_ipv4_addresses']:
self.facts['all_ipv4_addresses'].append(address)
else:
if address not in self.facts['all_ipv6_addresses']:
self.facts['all_ipv6_addresses'].append(address)
def parse_neighbors(self, data):
facts = dict()
for elem in data:
if 'lldpPortNbrInfoShort' not in elem:
continue
intf = str(elem['lldpPortNbrInfoShort']['port'])
if intf not in facts:
facts[intf] = list()
fact = dict()
fact['host'] = elem['lldpPortNbrInfoShort']['nbrSysName']
fact['port'] = str(elem['lldpPortNbrInfoShort']['nbrPortID'])
facts[intf].append(fact)
return facts
FACT_SUBSETS = dict(
default=Default,
hardware=Hardware,
interfaces=Interfaces,
config=Config)
VALID_SUBSETS = frozenset(FACT_SUBSETS.keys())
def main():
"""main entry point for module execution
"""
argument_spec = dict(
gather_subset=dict(default=["!config"], type='list')
)
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True)
gather_subset = module.params['gather_subset']
runable_subsets = set()
exclude_subsets = set()
for subset in gather_subset:
if subset == 'all':
runable_subsets.update(VALID_SUBSETS)
continue
if subset.startswith('!'):
subset = subset[1:]
if subset == 'all':
exclude_subsets.update(VALID_SUBSETS)
continue
exclude = True
else:
exclude = False
if subset not in VALID_SUBSETS:
module.fail_json(msg='Bad subset')
if exclude:
exclude_subsets.add(subset)
else:
runable_subsets.add(subset)
if not runable_subsets:
runable_subsets.update(VALID_SUBSETS)
runable_subsets.difference_update(exclude_subsets)
runable_subsets.add('default')
facts = dict()
facts['gather_subset'] = list(runable_subsets)
instances = list()
for key in runable_subsets:
instances.append(FACT_SUBSETS[key](module))
for inst in instances:
inst.populate()
facts.update(inst.facts)
ansible_facts = dict()
for key, value in iteritems(facts):
key = 'ansible_net_%s' % key
ansible_facts[key] = value
warnings = list()
module.exit_json(ansible_facts=ansible_facts)
if __name__ == '__main__':
main()

View file

@ -28,7 +28,7 @@ from ansible.plugins.terminal import TerminalBase
class TerminalModule(TerminalBase):
terminal_stdout_re = [
re.compile(br"[\r\n][\w\+\-\.:\/\[\]]+(?:\([^\)]+\)){0,3} (?:[>#]) ?$")
re.compile(br"[\r\n](?:! )?(?:\* )?(?:\(.*\) )?(?:Slot-\d+ )?\S+\.\d+ (?:[>#]) ?$")
]
terminal_stderr_re = [

View file

@ -0,0 +1 @@
[{"CLIoutput": "\n Neighbor Neighbor Neighbor\nPort Chassis ID Port ID TTL Age System Name\n===============================================================================\n1 00:02:02:02:02:02 1 120 26 EXOS-VM\n2 00:02:02:02:02:02 2 120 25 EXOS-VM\n3 00:02:02:02:02:02 3 120 25 EXOS-VM\n===============================================================================\nNOTE: The Chassis ID and/or Port ID might be truncated to fit the screen.\n\n"}, {"lldpPortNbrInfoShort": {"age": 26, "lastUpdate": 8412, "nbrChassisID": "00:02:02:02:02:02", "nbrChassisIdType": 4, "nbrIndex": 1, "nbrPortDescr": "Not-Advertised", "nbrPortID": 1, "nbrPortIdType": 5, "nbrSysDescr": "ExtremeXOS (EXOS-VM) version 30.1.0.27 xos_30.1 by lrichardson on Mon Apr 30 13:38:10 EDT 2018", "nbrSysName": "EXOS-VM", "nbrsOnThisPort": 1, "port": 1, "ttl": 120}, "status": "MORE"}, {"lldpPortNbrInfoShort": {"age": 25, "lastUpdate": 8412, "nbrChassisID": "00:02:02:02:02:02", "nbrChassisIdType": 4, "nbrIndex": 1, "nbrPortDescr": "Not-Advertised", "nbrPortID": 2, "nbrPortIdType": 5, "nbrSysDescr": "ExtremeXOS (EXOS-VM) version 30.1.0.27 xos_30.1 by lrichardson on Mon Apr 30 13:38:10 EDT 2018", "nbrSysName": "EXOS-VM", "nbrsOnThisPort": 1, "port": 2, "ttl": 120}, "status": "MORE"}, {"lldpPortNbrInfoShort": {"age": 25, "lastUpdate": 8417, "nbrChassisID": "00:02:02:02:02:02", "nbrChassisIdType": 4, "nbrIndex": 1, "nbrPortDescr": "Not-Advertised", "nbrPortID": 3, "nbrPortIdType": 5, "nbrSysDescr": "ExtremeXOS (EXOS-VM) version 30.1.0.27 xos_30.1 by lrichardson on Mon Apr 30 13:38:10 EDT 2018", "nbrSysName": "EXOS-VM", "nbrsOnThisPort": 1, "port": 3, "ttl": 120}, "status": "MORE"}, {"status": "SUCCESS"}]

View file

@ -0,0 +1 @@
[{"CLIoutput": "Port Configuration\nPort Virtual Port Link Auto Speed Duplex Flow Load Media\n router State State Neg Cfg Actual Cfg Actual Cntrl Master Pri Red\n================================================================================\n1 VR-Default E R OFF 25000 FULL NONE \n2 VR-Default E R OFF 25000 FULL NONE \n3 VR-Default E R OFF 25000 FULL NONE \n4 VR-Default E R OFF 25000 FULL NONE \n================================================================================\n> indicates Port Display Name truncated past 8 characters\nLink State: A-Active, R-Ready, NP-Port Not Present, L-Loopback\nPort State: D-Disabled, E-Enabled, L-License Disabled\nMedia: !-Unsupported, $-Unlicensed\nMedia Red: * - use \"show port info detail\" for redundant media type\nFlow Cntrl: Shows link partner's abilities. NONE if Auto Neg is OFF\n"}, {"show_ports_config": {"duplexActual": null, "duplexCfg": "FULL", "flowControl": null, "isAutoNegOn": 0, "licenseDisable": 0, "linkState": 0, "port": 1, "portList": "1-4", "portState": 1, "primaryMedia": " NONE", "speedActual": null, "speedCfg": 25000, "vrName": "VR-Default"}, "status": "MORE"}, {"show_ports_config": {"duplexActual": null, "duplexCfg": "FULL", "flowControl": null, "isAutoNegOn": 0, "licenseDisable": 0, "linkState": 0, "port": 2, "portList": "1-4", "portState": 1, "primaryMedia": " NONE", "speedActual": null, "speedCfg": 25000, "vrName": "VR-Default"}, "status": "MORE"}, {"show_ports_config": {"duplexActual": null, "duplexCfg": "FULL", "flowControl": null, "isAutoNegOn": 0, "licenseDisable": 0, "linkState": 0, "port": 3, "portList": "1-4", "portState": 1, "primaryMedia": " NONE", "speedActual": null, "speedCfg": 25000, "vrName": "VR-Default"}, "status": "MORE"}, {"show_ports_config": {"duplexActual": null, "duplexCfg": "FULL", "flowControl": null, "isAutoNegOn": 0, "licenseDisable": 0, "linkState": 0, "port": 4, "portList": "1-4", "portState": 1, "primaryMedia": " NONE", "speedActual": null, "speedCfg": 25000, "vrName": "VR-Default"}, "status": "SUCCESS"}]

View file

@ -0,0 +1,2 @@
[{"CLIoutput": "Port Display String Description String \n===== ==================== ==================================================\n1 Firewall\n2 \n3 Database Server\n4 \n===== ==================== ==================================================\n"}, {"show_ports_description": {"descriptionString": "Firewall", "port": 1, "portList": "1-4"}, "status": "MORE"}, {"show_ports_description": {"port": 2, "portList": "1-4"}, "status": "MORE"}, {"show_ports_description": {"descriptionString": "Database Server", "port": 3, "portList": "1-4"}, "status": "MORE"}, {"show_ports_description": {"port": 4, "portList": "1-4"}, "status": "SUCCESS"}]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,95 @@
System Memory Information
-------------------------
Total DRAM (KB): 8388608
System (KB): 357088
User (KB): 558460
Free (KB): 7473060
Memory Utilization Statistics
-----------------------------
Process Name Memory (KB)
-----------------------------
aaa 2212
acl 1637
bfd 1158
bgp 10031
brm 822
cfgmgr 2466
cli 16169
devmgr 884
dirser 463
dosprotect 570
dot1ag 1370
eaps 1359
edp 1260
elrp 1250
elsm 917
ems 3196
epm 1646
erps 1282
esrp 1101
ethoam 858
etmon 7865
exacl 0
exdhcpsnoop 0
exdos 0
exfib 0
exnasnoop 0
exosmc 0
exosq 0
expolicy 0
exsflow 0
exsnoop 0
exsshd 1522
exvlan 0
fdb 1990
hal 141451
hclag 899
idMgr 3448
ipSecurity 1042
ipfix 956
isis 1403
ismb 0
lacp 1306
lldp 1724
mcmgr 2183
mpls 0
mrp 1482
msdp 915
netLogin 1641
netTools 4336
nettx 0
nodealias 1847
nodemgr 501
ntp 812
openflow 0
ospf 1455
ospfv3 5130
otm 1095
ovsdb 8206
pim 2100
polMgr 479
policy 45998
pwmib 458
rip 1000
ripng 739
rtmgr 2679
snmpMaster 2798
snmpSubagent 5728
stp 2020
techSupport 681
telnetd 890
tftpd 336
throw 5262
thttpd 8944
twamp 471
upm 859
vlan 3215
vmt 1599
vpex 1771
vrrp 1185
vsm 1486
xmlc 1013
xmld 3468

View file

@ -0,0 +1,33 @@
SysName: X870-32c
SysLocation:
SysContact: support@extremenetworks.com, +1 888 257 3000
System MAC: 00:04:96:9A:B4:F7
System Type: X870-32c
SysHealth check: Enabled (Normal)
Recovery Mode: All
System Watchdog: Enabled
Current Time: Wed Jul 18 12:44:49 2018
Timezone: [Auto DST Disabled] GMT Offset: 0 minutes, name is UTC.
Boot Time: Tue Jul 17 12:49:58 2018
Boot Count: 4970
Next Reboot: None scheduled
System UpTime: 23 hours 54 minutes 50 seconds
Current State: OPERATIONAL
Image Selected: secondary
Image Booted: secondary
Primary ver: 30.1.0.37
Secondary ver: 22.5.1.7
Config Selected: primary.cfg
Config Booted: primary.cfg
Config Automatic: NONE (Disabled)
primary.cfg Created by ExtremeXOS version 22.6.0.11
983139 bytes saved on Wed Jun 6 16:59:49 2018
LAA MAC: Locally Administered MAC Address Disabled

View file

@ -0,0 +1,111 @@
#
# (c) 2018 Extreme Networks Inc.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
#
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
import json
from ansible.compat.tests.mock import patch
from ansible.modules.network.exos import exos_facts
from units.modules.utils import set_module_args
from .exos_module import TestExosModule, load_fixture
class TestExosFactsModule(TestExosModule):
module = exos_facts
def setUp(self):
super(TestExosFactsModule, self).setUp()
self.mock_run_commands = patch('ansible.modules.network.exos.exos_facts.run_commands')
self.run_commands = self.mock_run_commands.start()
def tearDown(self):
super(TestExosFactsModule, self).tearDown()
self.mock_run_commands.stop()
def load_fixtures(self, commands=None):
def load_from_file(*args, **kwargs):
module, commands = args
output = list()
fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures')
for command in commands:
filename = str(command).replace(' ', '_')
filename = os.path.join(fixture_path, filename)
with open(filename) as f:
data = f.read()
output.append(data)
return output
self.run_commands.side_effect = load_from_file
def test_exos_facts_default(self):
set_module_args(dict(gather_subset='default'))
result = self.execute_module()
self.assertEqual(
result['ansible_facts']['ansible_net_model'], 'X870-32c'
)
self.assertEqual(
result['ansible_facts']['ansible_net_serialnum'], '1604G-00175'
)
self.assertEqual(
result['ansible_facts']['ansible_net_version'], '22.5.1.7'
)
def test_exos_facts_hardware(self):
set_module_args(dict(gather_subset='hardware'))
result = self.execute_module()
self.assertEqual(
result['ansible_facts']['ansible_net_memfree_mb'], 7298
)
self.assertEqual(
result['ansible_facts']['ansible_net_memtotal_mb'], 8192
)
def test_exos_facts_interfaces(self):
set_module_args(dict(gather_subset='interfaces'))
result = self.execute_module()
self.assertEqual(
result['ansible_facts']['ansible_net_interfaces']['1']['bandwidth_configured'], '25000'
)
self.assertEqual(
result['ansible_facts']['ansible_net_interfaces']['3']['description'], 'Database Server'
)
self.assertEqual(
result['ansible_facts']['ansible_net_interfaces']['3']['type'], 'Ethernet'
)
self.assertEqual(
result['ansible_facts']['ansible_net_interfaces']['vlan1']['ipv4'][0]['address'], '10.0.1.1'
)
self.assertEqual(
result['ansible_facts']['ansible_net_interfaces']['vlan3']['ipv6'][0]['address'], 'fe80::202:b3ff:fe1e:8329'
)
self.assertEqual(
result['ansible_facts']['ansible_net_all_ipv4_addresses'], ['10.0.1.1', '192.168.1.1']
)
self.assertEqual(
result['ansible_facts']['ansible_net_all_ipv6_addresses'], ['fe80::202:b3ff:fe1e:8329']
)
self.assertEqual(
result['ansible_facts']['ansible_net_interfaces']['vlan3']['type'], 'VLAN'
)