From 8cf6b933d498eee73452d3175d43085634f66490 Mon Sep 17 00:00:00 2001
From: Robert Latta <robert.latta@geant.org>
Date: Tue, 23 Apr 2024 07:31:26 +0000
Subject: [PATCH] Feature/dboard3 920 populate snmp interface info

---
 inventory_provider/config.py            |  17 +--
 inventory_provider/nokia.py             | 144 ++++++++++++++++++++++--
 inventory_provider/routes/classifier.py |   5 +-
 inventory_provider/tasks/worker.py      | 116 +++++++++++++++----
 test/test_nokia.py                      |  52 +++++++--
 5 files changed, 279 insertions(+), 55 deletions(-)

diff --git a/inventory_provider/config.py b/inventory_provider/config.py
index 8e11f6e5..e1007de5 100644
--- a/inventory_provider/config.py
+++ b/inventory_provider/config.py
@@ -10,17 +10,6 @@ CONFIG_SCHEMA = {
             'maximum': 60,  # sanity
             'exclusiveMinimum': 0
         },
-        'database-credentials': {
-            'type': 'object',
-            'properties': {
-                'hostname': {'type': 'string'},
-                'dbname': {'type': 'string'},
-                'username': {'type': 'string'},
-                'password': {'type': 'string'}
-            },
-            'required': ['hostname', 'dbname', 'username', 'password'],
-            'additionalProperties': False
-        },
         'ssh-credentials': {
             'type': 'object',
             'properties': {
@@ -212,7 +201,6 @@ CONFIG_SCHEMA = {
 
     'type': 'object',
     'properties': {
-        'ops-db': {'$ref': '#/definitions/database-credentials'},
         'ssh': {'$ref': '#/definitions/ssh-credentials'},
         'nokia-ssh': {'$ref': '#/definitions/nokia-ssh-credentials'},
         'redis': {'$ref': '#/definitions/redis-credentials'},
@@ -234,7 +222,10 @@ CONFIG_SCHEMA = {
             'items': {'$ref': '#/definitions/interface-address'}
         },
         'gws-direct': {'$ref': '#/definitions/gws-direct'},
-        'nren-asn-map': {'$ref': '#/definitions/nren-asn-map'}
+        'nren-asn-map': {'$ref': '#/definitions/nren-asn-map'},
+        'nokia-community-inventory-provider': {'type': 'string'},
+        'nokia-community-dashboard': {'type': 'string'},
+        'nokia-community-brian': {'type': 'string'},
 
     },
     'oneOf': [
diff --git a/inventory_provider/nokia.py b/inventory_provider/nokia.py
index a19a47c7..640adaba 100644
--- a/inventory_provider/nokia.py
+++ b/inventory_provider/nokia.py
@@ -1,7 +1,7 @@
 import logging
 import re
 
-from ncclient import manager
+from ncclient import manager, xml_
 
 
 logger = logging.getLogger(__name__)
@@ -9,9 +9,11 @@ logger = logging.getLogger(__name__)
 
 ROOT_NS = 'urn:ietf:params:xml:ns:netconf:base:1.0'
 NOKIA_NS = 'urn:nokia.com:sros:ns:yang:sr:conf'
+NOKIA_STATE_NS = 'urn:nokia.com:sros:ns:yang:sr:state'
 NS = {
     'r': ROOT_NS,
     'n': NOKIA_NS,
+    's': NOKIA_STATE_NS,
 }
 
 BREAKOUT_PATTERN = re.compile(
@@ -34,19 +36,137 @@ SPEED_UNITS = {
     'g': 'Gbps',
     'G': 'Gbps',
 }
+STATE_FILTER = '''<filter>
+  <nokia-state:state xmlns:nokia-state="urn:nokia.com:sros:ns:yang:sr:state">
+    <nokia-state:port>
+        <nokia-state:port-id/>
+        <nokia-state:down-reason/>
+        <nokia-state:oper-state/>
+        <nokia-state:port-state/>
+        <nokia-state:type/>
+        <nokia-state:if-index/>
+        <nokia-state:ethernet>
+            <nokia-state:oper-speed/>
+        </nokia-state:ethernet>
+    </nokia-state:port>
+    <nokia-state:lag>
+        <nokia-state:lag-name/>
+        <nokia-state:oper-state/>
+        <nokia-state:if-index/>
+        <nokia-state:number-port-up/>
+        <nokia-state:port>
+            <nokia-state:port-id/>
+        </nokia-state:port>
+    </nokia-state:lag>
+    <nokia-state:router>
+      <nokia-state:interface>
+        <nokia-state:interface-name/>
+        <nokia-state:if-index/>
+        <nokia-state:oper-state/>
+        <nokia-state:protocol/>
+        <nokia-state:ipv4>
+            <nokia-state:oper-state/>
+            <nokia-state:down-reason/>
+            <nokia-state:primary>
+                <nokia-state:oper-address/>
+            </nokia-state:primary>
+            <nokia-state:secondary>
+                <nokia-state:address/>
+                <nokia-state:oper-address/>
+            </nokia-state:secondary>
+        </nokia-state:ipv4>
+        <nokia-state:ipv6>
+            <nokia-state:oper-state/>
+            <nokia-state:down-reason/>
+            <nokia-state:address>
+                <nokia-state:ipv6-address/>
+                <nokia-state:address-state/>
+                <nokia-state:oper-address/>
+                <nokia-state:primary-preferred/>
+            </nokia-state:address>
+        </nokia-state:ipv6>
+      </nokia-state:interface>
+    </nokia-state:router>
+  </nokia-state:state>
+</filter>'''
 
 
-def load_config(hostname, ssh_params, hostkey_verify=False):
+def load_docs(hostname, ssh_params, hostkey_verify=False):
     logger.info(f'capturing netconf data for "{hostname}"')
-    with manager.connect(
-        host=hostname,
-        hostkey_verify=hostkey_verify,
-        **ssh_params
-    ) as m:
-        return m.get_config(source='running').data
+    params = {
+        'host': hostname,
+        'hostkey_verify': hostkey_verify,
+        'device_params': {'name': 'sros'},
+        'nc_params': {'capabilities': ['urn:nokia.com:nc:pysros:pc']},
+        'timeout': 60
+    }
+    params.update(ssh_params)
+    with manager.connect(**params) as m:
+        # when adding the device_params to the connect call, the object returned from
+        # manager.get() and manager.get_config is a ncclient.xml_.NCElement object,
+        # which does not provide the data property this is a workaround to get the data
+        # property. The alternative would be to access the private _NCElement__result
+        # which is of type ncclient.operations.retrieve.GetReply and has a data property
+        running = xml_.to_ele(m.get_config(source='running').data_xml).getchildren()[0]
+        state = xml_.to_ele(m.get(filter=STATE_FILTER).data_xml).getchildren()[0]
+    return running, state
 
 
-def get_ports(netconf_config):
+def get_ports_state(state_doc):
+    def _port_info(e):
+        pi = {
+            'port-id': e.find('s:port-id', namespaces=NS).text,
+            'oper-state': e.find('s:oper-state', namespaces=NS).text,
+            'port-state': e.find('s:port-state', namespaces=NS).text,
+            'type': e.find('s:type', namespaces=NS).text,
+            'if-index': e.find('s:if-index', namespaces=NS).text,
+        }
+        down_reason = e.find('s:down-reason', namespaces=NS)
+        if down_reason is not None:
+            pi['down-reason'] = down_reason.text
+        return pi
+    ports = state_doc.findall('./s:state/s:port', namespaces=NS)
+    for port in ports:
+        yield _port_info(port)
+
+
+def get_lags_state(state_doc):
+    def _lag_info(e):
+        _name = e.find('s:lag-name', namespaces=NS).text
+        _state = e.find('s:oper-state', namespaces=NS).text
+        _if_index = e.find('s:if-index', namespaces=NS).text
+        _number_port_up = e.find('s:number-port-up', namespaces=NS).text
+        ports = [p.find('s:port-id', namespaces=NS).text for p in e.findall('s:port', namespaces=NS)]
+        return {
+            'name': _name,
+            'oper-state': _state,
+            'if-index': _if_index,
+            'number-port-up': _number_port_up,
+            'ports': ports,
+        }
+    lags = state_doc.findall('./s:state/s:lag', namespaces=NS)
+    for lag in lags:
+        yield _lag_info(lag)
+
+
+def get_interfaces_state(state_doc):
+    interfaces = state_doc.findall('./s:state/s:router/s:interface', namespaces=NS)
+    for interface_ in interfaces:
+        details = {
+            "interface-name": interface_.find('s:interface-name', namespaces=NS).text,
+            "if-index": interface_.find('s:if-index', namespaces=NS).text,
+            "oper-state": interface_.find('s:oper-state', namespaces=NS).text,
+        }
+        ipv4 = interface_.find('s:ipv4', namespaces=NS)
+        if ipv4 is not None:
+            details['ipv4-state'] = ipv4.find('s:oper-state', namespaces=NS).text
+        ipv6 = interface_.find('s:ipv6', namespaces=NS)
+        if ipv6 is not None:
+            details['ipv6-state'] = ipv6.find('s:oper-state', namespaces=NS).text
+        yield details
+
+
+def get_ports_config(netconf_config):
     def _port_info(e):
         pi = {
             'port-id': e.find('n:port-id', namespaces=NS).text,
@@ -81,7 +201,7 @@ def get_ports(netconf_config):
         yield port_info
 
 
-def get_lags(netconf_config):
+def get_lags_config(netconf_config):
     def _lag_info(e):
         _name = e.find('./n:lag-name', namespaces=NS).text
 
@@ -97,7 +217,7 @@ def get_lags(netconf_config):
             'description': (
                 description_e.text if description_e is not None else ''
             ),
-            'admin-status': admin_state,
+            'admin-state': admin_state,
             'ports': ports,
         }
         return ifc
@@ -107,7 +227,7 @@ def get_lags(netconf_config):
         yield _lag_info(lag)
 
 
-def interface_info(netconf_config):
+def get_interfaces_config(netconf_config):
 
     def _get_ip_address(e):
         for details_parent in e:
diff --git a/inventory_provider/routes/classifier.py b/inventory_provider/routes/classifier.py
index bb051c5f..5266bf84 100644
--- a/inventory_provider/routes/classifier.py
+++ b/inventory_provider/routes/classifier.py
@@ -248,9 +248,12 @@ def _link_interface_info(r, hostname, interface):
     if snmp_info:
         snmp_info = json.loads(snmp_info.decode('utf-8'))
         ifc_info['snmp'] = {
-            'community': snmp_info['community'],
             'index': snmp_info['index']
         }
+        if 'communities' in snmp_info:
+            ifc_info['snmp']['community'] = snmp_info['communities']['dashboard']
+        else:
+            ifc_info['snmp']['community'] = snmp_info['community']
 
     return ifc_info
 
diff --git a/inventory_provider/tasks/worker.py b/inventory_provider/tasks/worker.py
index 627985b1..c1800b4f 100644
--- a/inventory_provider/tasks/worker.py
+++ b/inventory_provider/tasks/worker.py
@@ -103,6 +103,22 @@ def _unmanaged_interfaces():
         InventoryTask.config.get('unmanaged-interfaces', []))
 
 
+def _nokia_community_strings(config_):
+    return {
+        'inventory-provider': config_['nokia-community-inventory-provider'],
+        'dashboard': config_['nokia-community-dashboard'],
+        'brian': config_['nokia-community-brian']
+    }
+
+
+def _general_community_strings(community):
+    return {
+        'inventory-provider': community,
+        'dashboard': community,
+        'brian': community
+    }
+
+
 @log_task_entry_and_exit
 def refresh_juniper_bgp_peers(hostname, netconf):
     host_peerings = list(juniper.all_bgp_peers(netconf))
@@ -615,42 +631,96 @@ def _reload_router_config_nokia(
         warning_callback=lambda s: None):
     info_callback(
         f'loading netconf data for {"lab " if lab else ""} {hostname}')
-    netconf_doc = retrieve_and_persist_netconf_config_nokia(
+    netconf_doc, state_doc = retrieve_and_persist_config_nokia(
         hostname, lab, warning_callback)
     r = get_next_redis(InventoryTask.config)
     refresh_nokia_interface_list(hostname, netconf_doc, r, lab)
+    communities = _nokia_community_strings(InventoryTask.config)
+    snmp_refresh_interfaces_nokia(hostname, state_doc, communities, r, info_callback)
 
 
-def retrieve_and_persist_netconf_config_nokia(
-        hostname, lab=False, update_callback=lambda s: None):
-    redis_key = f'netconf-nokia:{hostname}'
+def retrieve_and_persist_config_nokia(
+        hostname, lab=False, update_callback=lambda s: None
+):
+    redis_netconf_key = f'netconf-nokia:{hostname}'
+    redis_state_key = f'nokia-state:{hostname}'
     if lab:
-        redis_key = f'lab:{redis_key}'
+        redis_netconf_key = f'lab:{redis_netconf_key}'
+        redis_state_key = f'lab:{redis_state_key}'
 
     try:
-        netconf_config = nokia.load_config(
-            hostname, InventoryTask.config["nokia-ssh"])
-        netconf_str = etree.tostring(netconf_config)
+        netconf_config, state = nokia.load_docs(
+            hostname, InventoryTask.config['nokia-ssh']
+        )
     except (ConnectionError, TransportError) as e:
-        msg = f'error loading netconf data from {hostname}'
+        msg = f'error loading nokia data from {hostname}'
         logger.exception(e)
         logger.exception(msg)
         update_callback(msg)
         r = get_current_redis(InventoryTask.config)
-
-        netconf_str = r.get(redis_key)
+        netconf_str = r.get(redis_netconf_key)
+        state_str = r.get(redis_state_key)
+        failed_docs = []
+        failed_keys = []
         if not netconf_str:
-            update_callback(f'no cached netconf for {redis_key}')
+            failed_docs.append('netconf')
+            failed_keys.append(redis_netconf_key)
+        if not state_str:
+            failed_docs.append('port state')
+            failed_keys.append(redis_state_key)
+        update_callback(f'no cached info for {failed_keys}')
+        if failed_docs:
             raise InventoryTaskError(
-                f'netconf error with {hostname}'
-                f' and no cached netconf data found')
+                f'Nokia doc error with {hostname}'
+                f' and no cached data found for {failed_docs}'
+            )
         netconf_config = etree.fromstring(netconf_str)
-        update_callback(f'Returning cached netconf data for {hostname}')
+        state = etree.fromstring(state_str)
+        update_callback(f'Returning cached nokia data for {hostname}')
+    else:
+        netconf_str = etree.tostring(netconf_config)
+        state_str = etree.tostring(state)
 
-    r = get_next_redis(InventoryTask.config)
-    r.set(redis_key, netconf_str)
-    logger.info(f'netconf info loaded from {hostname}')
-    return netconf_config
+    rw = get_next_redis(InventoryTask.config)
+    rwp = rw.pipeline()
+    rwp.set(redis_netconf_key, netconf_str)
+    rwp.set(redis_state_key, state_str)
+    rwp.execute()
+    logger.info(f'Nokia docs info loaded from {hostname}')
+    return netconf_config, state
+
+
+@log_task_entry_and_exit
+def snmp_refresh_interfaces_nokia(
+        hostname, state_doc, communities, redis, update_callback=lambda s: None):
+
+    def _interface_info(interface_, name_field):
+        return {
+            'name': interface_[name_field],
+            'index': interface_['if-index'],
+            'communities': communities
+        }
+
+    interfaces = (_interface_info(ifc, 'interface-name') for ifc in
+                  nokia.get_interfaces_state(state_doc))
+    ports = (_interface_info(port, 'port-id') for port in
+             nokia.get_ports_state(state_doc))
+    lags = (_interface_info(lag, 'name') for lag in
+            nokia.get_lags_state(state_doc))
+    all_interfaces = list(itertools.chain(interfaces, ports, lags))
+
+    rp = redis.pipeline()
+    rp.set(f'snmp-interfaces:{hostname}', json.dumps(all_interfaces))
+
+    for ifc in all_interfaces:
+        ifc['hostname'] = hostname
+        rp.set(
+            f'snmp-interfaces-single:{hostname}:{ifc["name"]}',
+            json.dumps(ifc))
+
+    rp.execute()
+
+    update_callback(f'snmp interface info loaded from {hostname}')
 
 
 def refresh_nokia_interface_list(hostname, netconf_config, redis, lab=False):
@@ -671,10 +741,10 @@ def refresh_nokia_interface_list(hostname, netconf_config, redis, lab=False):
         rp.delete(k)
     rp.execute()
 
-    ports_by_port_id = {p['port-id']: p for p in nokia.get_ports(netconf_config)}
-    lags_by_name = {lag['name']: lag for lag in nokia.get_lags(netconf_config)}
+    ports_by_port_id = {p['port-id']: p for p in nokia.get_ports_config(netconf_config)}
+    lags_by_name = {lag['name']: lag for lag in nokia.get_lags_config(netconf_config)}
     interfaces_by_name = \
-        {ifc['interface-name']: ifc for ifc in nokia.interface_info(netconf_config)}
+        {ifc['interface-name']: ifc for ifc in nokia.get_interfaces_config(netconf_config)}
 
     def _save_interfaces_details(_details, _rp):
         _rp.set(
@@ -934,6 +1004,8 @@ def snmp_refresh_interfaces_juniper(
         interfaces = json.loads(interfaces.decode('utf-8'))
         update_callback(f'using cached snmp interface data for {hostname}')
 
+    for ifc in interfaces:
+        ifc['communities'] = _general_community_strings(community)
     r = get_next_redis(InventoryTask.config)
 
     rp = r.pipeline()
diff --git a/test/test_nokia.py b/test/test_nokia.py
index 2c4eb0a1..a55f331d 100644
--- a/test/test_nokia.py
+++ b/test/test_nokia.py
@@ -2,23 +2,24 @@ import pathlib
 
 from lxml import etree
 
-from inventory_provider.nokia import get_lags, interface_info, get_ports
+from inventory_provider.nokia import get_lags_config, get_interfaces_config, \
+    get_ports_config, get_ports_state, get_lags_state, get_interfaces_state
 
 netconf_doc = etree.parse(pathlib.Path(__file__).parent.joinpath(
     'data/rt0.lon.uk.lab.office.geant.net-netconf-nokia.xml'))
+state_doc = etree.parse(pathlib.Path(__file__).parent.joinpath(
+    'data/rt0.lon.uk.lab.office.geant.net-netconf-nokia-state.xml'))
 
 
 def test_get_lags():
-    lags = list(get_lags(netconf_doc))
-    assert len(lags) == 4
+    lags = list(get_lags_config(netconf_doc))
     found_names = {lag['name'] for lag in lags}
     expected_names = {'lag-1', 'lag-2', 'lag-3', 'lag-31'}
     assert found_names == expected_names
 
 
 def test_interface_info():
-    if_addresses = list(interface_info(netconf_doc))
-    assert len(if_addresses) == 5
+    if_addresses = list(get_interfaces_config(netconf_doc))
     found_names = {_interface['interface-name'] for _interface in if_addresses}
     expected_names = {'lag-1.0', 'lag-2.0', 'system', 'to_rt0_ams_ZR-INFINERA',
                       'to_rt0_ams_ZR-NOKIA'}
@@ -26,8 +27,7 @@ def test_interface_info():
 
 
 def test_get_ports():
-    ports = list(get_ports(netconf_doc))
-    assert len(ports) == 18
+    ports = list(get_ports_config(netconf_doc))
     found_port_ids = {port['port-id'] for port in ports}
     expected_ports = {
         '1/1/c2', '1/1/c2/1', '1/1/c2/2', '1/1/c2/3', '1/1/c7', '1/1/c7/1',
@@ -35,3 +35,41 @@ def test_get_ports():
         '2/1/c7', '2/1/c7/1', '2/1/c8', '2/1/c8/1', '2/1/c13', '2/1/c13/1',
     }
     assert found_port_ids == expected_ports
+
+
+def test_get_port_state():
+    ports = {p['port-id']: p for p in get_ports_state(state_doc)}
+    assert len(ports) == 197
+    found_port_ids = {p_id for p_id in ports}
+    ports_sample = {
+        '1/1/c2', '1/1/c2/1', '1/1/c2/2', '1/1/c2/3', '1/1/c7', '1/1/c7/1',
+        '1/1/c8', '1/1/c8/1', '1/1/c9', '1/1/c9/1', '1/1/c13', '1/1/c13/1',
+        '2/1/c7', '2/1/c7/1', '2/1/c8', '2/1/c8/1', '2/1/c13', '2/1/c13/1',
+    }
+    assert found_port_ids > ports_sample
+    expected_up_ports = {
+        '1/1/c2', '1/1/c5', '1/1/c5/1', '1/1/c7', '1/1/c8', '1/1/c9', '1/1/c13',
+        '1/1/c13/1', '1/1/c19', '1/1/c26', '1/1/c26/1', '1/1/c27', '1/1/c27/1',
+        '1/1/c30', '2/1/c7', '2/1/c8', '2/1/c13', '2/1/c13/1', 'A/1', 'B/1'
+    }
+    assert {k for k, v in ports.items() if v['oper-state'] == 'up'} == expected_up_ports
+
+
+def test_get_lag_state():
+    lags = {x['name']: x for x in get_lags_state(state_doc)}
+    found_names = {lag_name for lag_name in lags}
+    expected_names = {'lag-1', 'lag-2', 'lag-3'}
+    assert found_names == expected_names
+    expected_up_lags = {'lag-2'}
+    assert {k for k, v in lags.items() if v['oper-state'] == 'up'} == expected_up_lags
+
+
+def test_get_interface_state():
+    interfaces = {x['interface-name']: x for x in get_interfaces_state(state_doc)}
+    found_names = {interface_name for interface_name in interfaces}
+    expected_names = {'system', 'lag-1.0', 'lag-2.0', 'lag-3.0', 'exfo400', 'guy',
+                      'exfo400-100', 'management'}
+    assert found_names == expected_names
+    expected_up_interfaces = {'system', 'lag-2.0', 'guy', 'exfo400-100', 'management'}
+    assert {k for k, v in interfaces.items() if
+            v['oper-state'] == 'up'} == expected_up_interfaces
-- 
GitLab