diff --git a/brian_polling_manager/interface_stats/cli.py b/brian_polling_manager/interface_stats/cli.py index 8783169ad64bc46b088f21fb91e2fb515cd36d0e..196cb94d6f6860489aabe6092d687d6bfb36a508 100644 --- a/brian_polling_manager/interface_stats/cli.py +++ b/brian_polling_manager/interface_stats/cli.py @@ -1,11 +1,11 @@ import enum import json +from logging import LogRecord import logging.config -import os import socket import sys from datetime import datetime -from typing import Iterable, List, Sequence +from typing import Iterable, List, Optional, Collection import click import jsonschema @@ -15,29 +15,16 @@ from brian_polling_manager.interface_stats.vendors import Vendor, juniper, nokia from brian_polling_manager.inventory import load_interfaces from lxml import etree -logger = logging.getLogger(__file__) - -LOGGING_DEFAULT_CONFIG = { - "version": 1, - "disable_existing_loggers": False, - "formatters": {"simple": {"format": "%(asctime)s - %(levelname)s - %(message)s"}}, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "level": "INFO", - "formatter": "simple", - "stream": "ext://sys.stdout", - }, - }, - "loggers": { - "brian_polling_manager": { - "level": "INFO", - "handlers": ["console"], - "propagate": False, - } - }, - "root": {"level": "INFO", "handlers": ["console"]}, -} +logger = logging.getLogger() + + +class MessageCounter(logging.NullHandler): + def __init__(self, level=logging.NOTSET) -> None: + super().__init__(level) + self.count = 0 + + def handle(self, record: LogRecord) -> None: + self.count += 1 class OutputMethod(enum.Enum): @@ -46,12 +33,10 @@ class OutputMethod(enum.Enum): NO_OUT = "no-out" -def setup_logging(): +def setup_logging(debug=False) -> MessageCounter: """ - set up logging using the configured filename - - if LOGGING_CONFIG is defined in the environment, use this for - the filename, otherwise use LOGGING_DEFAULT_CONFIG + :param debug: set log level to DEBUG, or INFO otherwise + :returns: a MessageCounter object that tracks error log messages """ # demote ncclient logs @@ -61,16 +46,24 @@ def setup_logging(): record.levelname = "DEBUG" return record - logging.getLogger("ncclient.transport.ssh").addFilter(changeLevel) - logging.getLogger("ncclient.operations.rpc").addFilter(changeLevel) - - logging_config = LOGGING_DEFAULT_CONFIG - if "LOGGING_CONFIG" in os.environ: - filename = os.environ["LOGGING_CONFIG"] - with open(filename) as f: - logging_config = json.loads(f.read()) + def drop(record): + pass - logging.config.dictConfig(logging_config) + logging.getLogger("ncclient.operations.rpc").addFilter(changeLevel) + logging.getLogger("ncclient.transport.tls").addFilter(changeLevel) + logging.getLogger("ncclient.transport.ssh").addFilter(drop) + logging.getLogger("ncclient.transport.parser").addFilter(drop) + + level = logging.DEBUG if debug else logging.INFO + counter = MessageCounter(level=logging.ERROR) + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setLevel(level) + logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", + level=level, + handlers=[counter, stream_handler], + ) + return counter def write_points( @@ -109,35 +102,28 @@ def get_netconf(router_name, vendor: Vendor, ssh_params: dict, **kwargs): ) -def validate_router_hosts( - hostnames: List[str], - vendor: Vendor, - inprov_hosts=None, - load_interfaces_=load_interfaces, -): - if inprov_hosts is None: - return True +def get_interfaces_for_router( + router: str, + inprov_hosts: List[str], +) -> List[str]: + logger.info(f"Fetching interfaces from inventory provider: {inprov_hosts}") - if vendor == Vendor.NOKIA: - logger.info(f"Skipping validation for Nokia host(s) {' '.join(hostnames)}") - return True + all_interfaces = [ + ifc["name"] for ifc in load_interfaces(inprov_hosts) if ifc["router"] == router + ] - logger.info( - f"Validating hosts {' '.join(hostnames)} using providers {inprov_hosts}" - ) - all_fqdns = {ifc["router"] for ifc in load_interfaces_(inprov_hosts)} + if not all_interfaces: + raise click.ClickException(f"No interfaces found for router {router}") - extra_fqdns = set(hostnames) - set(all_fqdns) - if extra_fqdns: - raise ValueError( - f"Routers are not in inventory provider or not {vendor.value}: " - f"{' '.join(extra_fqdns)}" - ) - return True + return all_interfaces def process_router( - router_fqdn: str, vendor: Vendor, app_config_params: dict, output: OutputMethod + router_fqdn: str, + vendor: Vendor, + interfaces: Optional[List[str]], + app_config_params: dict, + output: OutputMethod, ): logger.info(f"Processing {vendor.value.capitalize()} router {router_fqdn}") @@ -152,6 +138,7 @@ def process_router( _brian_points( router_fqdn=router_fqdn, netconf_doc=document, + interfaces=interfaces, timestamp=timestamp, measurement_name=influx_params["measurement"], vendor=vendor, @@ -166,6 +153,7 @@ def process_router( _error_points( router_fqdn=router_fqdn, netconf_doc=document, + interfaces=interfaces, timestamp=timestamp, measurement_name=influx_params["measurement"], vendor=vendor, @@ -179,12 +167,13 @@ def process_router( def _brian_points( router_fqdn: str, netconf_doc: etree.Element, + interfaces: Optional[List[str]], timestamp: datetime, measurement_name: str, vendor: Vendor, ): module = juniper if vendor == Vendor.JUNIPER else nokia - interfaces = module.interface_counters(netconf_doc) + interfaces = module.interface_counters(netconf_doc, interfaces=interfaces) yield from vendors.brian_points( router_fqdn, interfaces, timestamp, measurement_name ) @@ -193,18 +182,19 @@ def _brian_points( def _error_points( router_fqdn: str, netconf_doc: etree.Element, + interfaces: Optional[List[str]], timestamp: datetime, measurement_name: str, vendor: Vendor, ): module = juniper if vendor == Vendor.JUNIPER else nokia - interfaces = module.interface_counters(netconf_doc) + interfaces = module.interface_counters(netconf_doc, interfaces=interfaces) yield from vendors.error_points( router_fqdn, interfaces, timestamp, measurement_name ) -def _log_interface_points_sorted(points: Sequence[dict], point_kind=""): +def _log_interface_points_sorted(points: Collection[dict], point_kind=""): N_COLUMNS = 5 num_points = len(points) point_kind = point_kind + " " if point_kind else "" @@ -222,40 +212,40 @@ def _log_interface_points_sorted(points: Sequence[dict], point_kind=""): logger.info(" ".join(i.ljust(longest_ifc) for i in ifc_slice)) +ALL_ = object() + + def main( app_config_params: dict, - router_fqdns: List[str], + router_fqdn: str, vendor: Vendor, output: OutputMethod = OutputMethod.INFLUX, - raise_errors=False, + interfaces=ALL_, ): vendor_str = vendor.value inprov_hosts = app_config_params.get("inventory") - validate_router_hosts(router_fqdns, vendor=vendor, inprov_hosts=inprov_hosts) - if not app_config_params.get(vendor_str): raise ValueError(f"'{vendor_str}' ssh params are required") - error_count = 0 - for router in router_fqdns: - try: - process_router( - router_fqdn=router, - vendor=vendor, - app_config_params=app_config_params, - output=output, + # if we choose to write points for all interfaces and we have provided inventory + # provider hosts, we make a selection based on the interfaces. Otherwise we write + # points for all interfaces we find on the router + if interfaces is ALL_: + if inprov_hosts is not None: + interfaces = get_interfaces_for_router( + router_fqdn, inprov_hosts=inprov_hosts ) - except Exception: - logger.exception( - f"Error while processing {vendor_str.capitalize()} router {router}" - ) - error_count += 1 - - if raise_errors: - raise + else: + interfaces = None - return error_count + process_router( + router_fqdn=router_fqdn, + vendor=vendor, + interfaces=interfaces, + app_config_params=app_config_params, + output=output, + ) def validate_config(_unused_ctx, _unused_param, file): @@ -292,13 +282,11 @@ def validate_hostname(_unused_ctx, _unused_param, hostname_or_names): ) @click.option( "--juniper", - is_flag=True, - help="The given router fqdns are juniper routers", + help="A Juniper router fqdn", ) @click.option( "--nokia", - is_flag=True, - help="The given router fqdns are nokia routers", + help="A Nokia router fqdn", ) @click.option( "-o", @@ -307,31 +295,63 @@ def validate_hostname(_unused_ctx, _unused_param, hostname_or_names): default="influx", help="Choose an output method. Default: influx", ) -@click.argument("router-fqdn", nargs=-1, callback=validate_hostname) +@click.option( + "--all", + "all_", + is_flag=True, + default=False, + help=( + "Write points for all interfaces found in inventory provider for this router." + " Do not use this flag when supplying a list of interfaces" + ), +) +@click.option( + "-v", + "--verbose", + is_flag=True, + default=False, + help="Run with verbose output", +) +@click.argument("interfaces", nargs=-1) def cli( app_config_params: dict, juniper: bool, nokia: bool, output: str, - router_fqdn: List[str], + all_: bool, + verbose: bool, + interfaces: List[str], ): - if not router_fqdn: - # Do nothing if no routers are specified + if not (interfaces or all_): + # Do nothing if no interfaces are specified return - if not (juniper ^ nokia): - raise click.BadParameter("Set either '--juniper' or '--nokia', but not both") + if interfaces and all_: + raise click.BadParameter("Do not supply both 'interfaces' and '--all'") + + if not (juniper or nokia) or (juniper and nokia): + raise click.BadParameter( + "Supply either a '--juniper' or '--nokia' router, but not both" + ) + router_fqdn = juniper or nokia vendor = Vendor.JUNIPER if juniper else Vendor.NOKIA - setup_logging() + error_counter = setup_logging(debug=verbose) - error_count = main( - app_config_params=app_config_params, - router_fqdns=router_fqdn, - vendor=vendor, - output=OutputMethod(output.lower()), - ) - if error_count: + try: + main( + app_config_params=app_config_params, + router_fqdn=router_fqdn, + vendor=vendor, + output=OutputMethod(output.lower()), + interfaces=interfaces if interfaces else ALL_, + ) + except Exception: + logger.exception( + f"Error while processing {vendor.value.capitalize()} router {router_fqdn}" + ) + + if error_counter.count: raise click.ClickException( "Errors were encountered while processing interface stats" ) diff --git a/brian_polling_manager/interface_stats/config.py b/brian_polling_manager/interface_stats/config.py index 561658448684b96f96bead3e96c823e487e3f64c..0b8ead0941893a94070b53a9ada658860a4d0ff0 100644 --- a/brian_polling_manager/interface_stats/config.py +++ b/brian_polling_manager/interface_stats/config.py @@ -36,7 +36,6 @@ CONFIG_SCHEMA = { "measurement": {"type": "string"}, }, "required": [ - # ssl, port are optional "hostname", "username", "password", @@ -50,6 +49,8 @@ CONFIG_SCHEMA = { "properties": { "juniper": {"$ref": "#/definitions/ssh-params"}, "nokia": {"$ref": "#/definitions/ssh-params"}, + # TODO: once this script has become stable, and inprov nokia routers are added + # to inprov, we can make inventory a required property "inventory": { "type": "array", "items": {"type": "string", "format": "uri"}, diff --git a/brian_polling_manager/interface_stats/vendors/common.py b/brian_polling_manager/interface_stats/vendors/common.py index 91e08dcd3ba0489aaf15ab6677a8b02256b27c53..be465560fb801bcb4b4525eff1ef62e1a96da446 100644 --- a/brian_polling_manager/interface_stats/vendors/common.py +++ b/brian_polling_manager/interface_stats/vendors/common.py @@ -1,6 +1,7 @@ import contextlib import logging from functools import partial +from typing import Set import ncclient.manager from lxml import etree @@ -142,28 +143,103 @@ def error_points(router_fqdn, interfaces, timestamp, measurement_name): return filter(lambda r: r is not None, map(point_creator, interfaces)) +class ParsingError(ValueError): + pass + + +def extract_all_counters(iterable, struct): + """ + extract counters for all interfaces that can be parsed. Skip interfaces that can't + be parsed because they lack required fields + + :param iterable: an xpath iterable for interface Element + :param struct: one of + juniper.PHYSICAL_INTERFACE_COUNTERS + juniper.LOGICAL_INTERFACE_COUNTERS + nokia.INTERFACE_COUNTERS + nokia.INTERFACE_COUNTERS_ALT + """ + for ifc in iterable: + try: + result = parse_interface_xml(ifc, struct) + except ParsingError: + continue + yield result + + +def extract_selected_counters(iterable, struct, interfaces: Set[str]): + """ + Extract counters for interfaces in ``interfaces``. Logs an error if an interface + can't be parsed because it lacks missing required fields. + + :param iterable: an xpath iterable for interface Element + :param struct: one of + juniper.PHYSICAL_INTERFACE_COUNTERS + juniper.LOGICAL_INTERFACE_COUNTERS + nokia.INTERFACE_COUNTERS + nokia.INTERFACE_COUNTERS_ALT + :param interfaces: a set of interface names. This may be modified by this function + in place + """ + for ifc in iterable: + if not interfaces: + return + + name = parse_interface_xml(ifc, struct["name"]) + if name not in interfaces: + continue + try: + result = parse_interface_xml(ifc, struct) + except ParsingError as e: + logger.error(f"Error parsing netconf for interface {name}: {e}") + continue + finally: + interfaces.remove(name) + yield result + + def parse_interface_xml(node, struct: dict, defaults=None): """ - struct should be one of: + parses an lxml.etree.Element xml tree and returns either a dictionary or a + primitive (a single value). ``struct`` is the shape of the output. For a primitive + the struct can have the following keys: + * ``path`` an xpath expression to lookup a value. Alternatively a list of xpath + expressions may be given, in which case the first found value will be returned + * ``transform`` a function that takes a raw value (str) and returns a transformed + value, (eg. ``int`` to cast to integer) + * ``required`` boolean. Indicate whether the field must be present. Raises + ParsingError if the path is not found in the node. If required is set to False, + ``None`` may be returned + + To parse multiple values, ``struct`` may be a nested dict of primitive structs. In + that case the output follows the shape of ``struct`` until a primitives struct is + encountered, which it will resolve into a primitve value. (determined by whether + ``path`` is a key of the struct). + + At any level a ``__defaults__`` key may be given that will be used as defaults for + any lower level primitive structs. + + see the following structs for examples juniper.PHYSICAL_INTERFACE_COUNTERS juniper.LOGICAL_INTERFACE_COUNTERS - nokia.PORT_COUNTERS - nokia.LAG_COUNTERS + nokia.INTERFACE_COUNTERS + nokia.INTERFACE_COUNTERS_ALT :param node: an lxml.etree.Element - :param struct: one of the dicts listed above + :param struct: A (nested) dict / primitive struct :param defaults: :return: """ defaults = struct.get("__defaults__", defaults) or {} + + if "path" in struct: + return _read_counter(node, **{**defaults, **struct}) + result = {} for key, val in struct.items(): if key == "__defaults__": continue - if "path" in val: - parsed = _read_counter(node, **{**defaults, **val}) - else: - parsed = parse_interface_xml(node, val, defaults) + parsed = parse_interface_xml(node, val, defaults) if parsed is None: continue result[key] = parsed @@ -177,11 +253,11 @@ def _read_counter(node, path, transform, required=False): _elems = node.xpath(p + "/text()") if _elems: if len(_elems) > 1: - logger.warning(f"found more than one {p} in {node}") + logger.warning(f"found more than one element {p}") return transform(_elems[0]) if required: - logger.error(f"required path {p} not found in {node}") + raise ParsingError(f"required path {p} not found") @contextlib.contextmanager diff --git a/brian_polling_manager/interface_stats/vendors/juniper.py b/brian_polling_manager/interface_stats/vendors/juniper.py index 2dd61410dbd5e1e81b9e0f35805608c9ad855e26..000ab135a9dbc8da00bfe5ab3a16c8e9e21af86f 100644 --- a/brian_polling_manager/interface_stats/vendors/juniper.py +++ b/brian_polling_manager/interface_stats/vendors/juniper.py @@ -1,9 +1,11 @@ import logging import pathlib +from typing import Optional, Collection from brian_polling_manager.interface_stats.vendors.common import ( + extract_all_counters, + extract_selected_counters, netconf_connect, - parse_interface_xml, remove_xml_namespaces, ) from lxml import etree @@ -135,45 +137,27 @@ LOGICAL_INTERFACE_COUNTERS = { } -def _physical_interface_counters(ifc_doc): - for phy in ifc_doc.xpath( - "//interface-information/physical-interface[" - '(name[(starts-with(normalize-space(), "xe-"))]' - ' or name[(starts-with(normalize-space(), "ge-"))]' - ' or name[(starts-with(normalize-space(), "et-"))]' - ' or name[(starts-with(normalize-space(), "ae"))]' - ' or name[(starts-with(normalize-space(), "irb"))]' - ' or name[(starts-with(normalize-space(), "gr"))])' - # ' and normalize-space(admin-status)="up"' - # ' and normalize-space(oper-status)="up"' - "]" - ): - result = parse_interface_xml(phy, PHYSICAL_INTERFACE_COUNTERS) - assert result - yield result - - -def _logical_interface_counters(ifc_doc): - for logical in ifc_doc.xpath( - "//interface-information/physical-interface[" - '(name[(starts-with(normalize-space(), "xe-"))]' - ' or name[(starts-with(normalize-space(), "ge-"))]' - ' or name[(starts-with(normalize-space(), "et-"))]' - ' or name[(starts-with(normalize-space(), "ae"))]' - ' or name[(starts-with(normalize-space(), "irb"))]' - ' or name[(starts-with(normalize-space(), "gr"))])' - # ' and normalize-space(admin-status)="up"' - # ' and normalize-space(oper-status)="up"' - "]/logical-interface" - ): - result = parse_interface_xml(logical, LOGICAL_INTERFACE_COUNTERS) - assert result - yield result - - -def interface_counters(ifc_doc): - yield from _physical_interface_counters(ifc_doc) - yield from _logical_interface_counters(ifc_doc) +def interface_counters(ifc_doc, interfaces: Optional[Collection[str]] = None): + phy_interfaces = ifc_doc.xpath("//interface-information/physical-interface") + log_interfaces = ifc_doc.xpath( + "//interface-information/physical-interface/logical-interface" + ) + + if interfaces is None: + yield from extract_all_counters(phy_interfaces, PHYSICAL_INTERFACE_COUNTERS) + yield from extract_all_counters(log_interfaces, LOGICAL_INTERFACE_COUNTERS) + return + + remaining = set(interfaces) + yield from extract_selected_counters( + phy_interfaces, PHYSICAL_INTERFACE_COUNTERS, remaining + ) + yield from extract_selected_counters( + log_interfaces, LOGICAL_INTERFACE_COUNTERS, remaining + ) + + for ifc in remaining: + logger.error(f"Interface {ifc} was not found on router") def get_netconf_interface_info(router_name, ssh_params): diff --git a/brian_polling_manager/interface_stats/vendors/nokia.py b/brian_polling_manager/interface_stats/vendors/nokia.py index a4179adc006008644c33254df4b5c49e243baf0e..8025652a0bb5cdc8aae40b4eeb0d47f10f6d7242 100644 --- a/brian_polling_manager/interface_stats/vendors/nokia.py +++ b/brian_polling_manager/interface_stats/vendors/nokia.py @@ -1,8 +1,10 @@ import logging import pathlib +from typing import Dict, Optional, Sequence from brian_polling_manager.interface_stats.vendors.common import ( + extract_all_counters, + extract_selected_counters, netconf_connect, - parse_interface_xml, remove_xml_namespaces, ) from lxml import etree @@ -65,35 +67,42 @@ INTERFACE_COUNTERS_ALT = { } -def get_netconf_interface_info(router_name: str, ssh_params: dict): - STATE_NS = "urn:nokia.com:sros:ns:yang:sr:state" +def get_netconf_interface_info( + router_name: str, ssh_params: dict +) -> Dict[str, etree.ElementBase]: + """ + :param router_name: the router to poll + :param ssh_params: ssh params for connecting to the router + + :returns: a dictionary with doctype (``port``, ``lag`` & ``router-interface`` ) as + keys and their respective netconf document as values + """ - def query(*path: str): - # https://github.com/nokia/pysros/blob/main/examples/show_port_counters.py - # https://netdevops.me/2020/nokia-yang-tree-and-path-browser/ - # https://github.com/hellt/nokia-yangtree - - root = etree.Element("filter") - sub_elem = etree.SubElement( - root, f"{{{STATE_NS}}}state", nsmap={"nokia-state": STATE_NS} - ) - for p in path: - sub_elem = etree.SubElement(sub_elem, f"{{{STATE_NS}}}{p}") - return root - - def get_xml(conn, query_): - response = conn.get(filter=query_) - return remove_xml_namespaces(response.tostring.decode("utf-8")) - - queries = { - "port": query("port"), - "lag": query("lag"), - "router-interface": query("router", "interface"), + query_path = { + "port": ["port", "statistics"], + "lag": ["lag", "statistics"], + "router-interface": ["router", "interface"], } with netconf_connect( hostname=router_name, ssh_params=ssh_params, **NCCLIENT_PARAMS ) as conn: - return {ept: get_xml(conn, q) for ept, q in queries.items()} + return { + doctype: remove_xml_namespaces(query(conn, *qpath).tostring.decode("utf-8")) + for doctype, qpath in query_path.items() + } + + +def query(connection, *path): + STATE_NS = "urn:nokia.com:sros:ns:yang:sr:state" + + root = etree.Element("filter") + sub_elem = etree.SubElement( + root, f"{{{STATE_NS}}}state", nsmap={"nokia-state": STATE_NS} + ) + for p in path: + sub_elem = etree.SubElement(sub_elem, f"{{{STATE_NS}}}{p}") + + return connection.get(filter=root) def get_netconf_interface_info_from_source_dir(router_name: str, source_dir: str): @@ -110,42 +119,45 @@ def get_netconf_interface_info_from_source_dir(router_name: str, source_dir: str } -def _port_counters(state_doc: etree.Element): - """ - :param state_doc: /nokia-state:state/port yang node - :return: counters for all monitored ports - """ +def _port_xml(state_doc: etree.Element): + return state_doc.xpath("//data/state/port") - for elem in state_doc.xpath("//data/state/port"): - yield parse_interface_xml(elem, INTERFACE_COUNTERS) +def _lag_xml(state_doc: etree.Element): + return state_doc.xpath("//data/state/lag") -def _lag_counters(state_doc: etree.Element): - """ - :param state_doc: /nokia-state:state/lag yang node - :return: counters for all monitored lags - """ - - for elem in state_doc.xpath("//data/state/lag"): - yield parse_interface_xml(elem, INTERFACE_COUNTERS) - - -def _router_interface_counters(state_doc: etree.Element): - """ - :param state_doc: /nokia-state:state/router/interface yang node - :return: counters for all monitored "router interfaces" - """ - for elem in state_doc.xpath("//data/state/router/interface"): - yield parse_interface_xml(elem, INTERFACE_COUNTERS_ALT) +def _router_interface_xml(state_doc: etree.Element): + return state_doc.xpath("//data/state/router/interface") -def interface_counters(raw_counter_docs: dict): +def interface_counters( + raw_counter_docs: dict, interfaces: Optional[Sequence[str]] = None +): """ - - :param raw_counter_docs: output of a call to get_netconf_interface_info + :param raw_counter_docs: output of a call to ``get_netconf_interface_info`` + :pararm interfaces: a sequence of interfaces for which to retrieve counters. + ``None`` means return counters for all interfaces :return: iterable of interface counters """ - yield from _port_counters(raw_counter_docs["port"]) - yield from _lag_counters(raw_counter_docs["lag"]) - yield from _router_interface_counters(raw_counter_docs["router-interface"]) + doc_info = { + "port": (INTERFACE_COUNTERS, _port_xml), + "lag": (INTERFACE_COUNTERS, _lag_xml), + "router-interface": (INTERFACE_COUNTERS_ALT, _router_interface_xml), + } + + if interfaces is None: + for doctype, doc in raw_counter_docs.items(): + struct, xpath = doc_info[doctype] + yield from extract_all_counters(xpath(doc), struct) + return + + remaining = set(interfaces) + for doctype, doc in raw_counter_docs.items(): + struct, xpath = doc_info[doctype] + yield from extract_selected_counters(xpath(doc), struct, interfaces=remaining) + if not remaining: + return + + for ifc in remaining: + logger.error(f"Interface {ifc} was not found on router") diff --git a/test/interface_stats/conftest.py b/test/interface_stats/conftest.py index ed0a766c2d78f5600b04a79682f44d0ac4e594fc..82659a64af532784e97b9e2bc856abad94371f47 100644 --- a/test/interface_stats/conftest.py +++ b/test/interface_stats/conftest.py @@ -18,7 +18,7 @@ JUNIPER_ROUTERS = [ NOKIA_ROUTERS = list( { path.name[: -len(suffix)] - for suffix in {"-ports.xml", "-lags.xml"} + for suffix in {"-ports.xml", "-lags.xml", "-router-interfaces.xml"} for path in DATA_DIR.iterdir() if path.name.endswith(suffix) } @@ -53,11 +53,15 @@ def poller_interfaces(): @pytest.fixture(scope="session") -def polled_interfaces(): +def juniper_inventory(): + def _excluded(ifc): + return ifc["name"].startswith("dsc") + polled = {} for ifc in poller_interfaces(): - if ifc["dashboards"]: - polled.setdefault(ifc["router"], set()).add(ifc["name"]) + if _excluded(ifc): + continue + polled.setdefault(ifc["router"], set()).add(ifc["name"]) return polled diff --git a/test/interface_stats/test_interface_stats.py b/test/interface_stats/test_interface_stats.py index d87cfc2c7fed7fba65fafccaa2bf6735241148f0..8567b73bae31561fa23f1e59274b2eefdfaf881b 100644 --- a/test/interface_stats/test_interface_stats.py +++ b/test/interface_stats/test_interface_stats.py @@ -10,13 +10,13 @@ from lxml import etree import ncclient.manager -def test_sanity_check_juniper_snapshot_data(polled_interfaces, all_juniper_routers): +def test_sanity_check_juniper_snapshot_data(juniper_inventory, all_juniper_routers): """ verify that all routers with interfaces to be polled are in the test data set :return: """ - missing_routers = set(polled_interfaces.keys()) - set(all_juniper_routers) + missing_routers = set(juniper_inventory.keys()) - set(all_juniper_routers) assert len(missing_routers) == 0 @@ -28,20 +28,32 @@ def test_sanity_check_nokia_snapshot_data(all_nokia_routers): } -class TestParseCounters: - def test_parse_counters(self): +class TestParseInterfaceXML: + def test_parse_xml(self): xml = """<root><ab>42</ab></root>""" struct = {"something": {"path": "./ab", "transform": int}} result = common.parse_interface_xml(etree.fromstring(xml), struct) assert result == {"something": 42} - def test_parse_counters_multiple_path(self): + def test_parse_xml_deep(self): + xml = """<root><a><b>42</b></a></root>""" + struct = {"something": {"path": "./a/b", "transform": int}} + result = common.parse_interface_xml(etree.fromstring(xml), struct) + assert result == {"something": 42} + + def test_parse_xml_direct_to_value(self): + xml = """<root><ab>42</ab></root>""" + struct = {"path": "./ab", "transform": int} + result = common.parse_interface_xml(etree.fromstring(xml), struct) + assert result == 42 + + def test_parse_xml_multiple_path(self): xml = """<root><a>This is something</a></root>""" struct = {"something": {"path": ["./b", "./a"], "transform": str}} result = common.parse_interface_xml(etree.fromstring(xml), struct) assert result == {"something": "This is something"} - def test_parse_counters_nested(self): + def test_parse_xml_nested(self): xml = """<root><a>This is something</a></root>""" struct = {"something": {"nested": {"path": "./a", "transform": str}}} result = common.parse_interface_xml(etree.fromstring(xml), struct) @@ -56,16 +68,14 @@ class TestParseCounters: result = common.parse_interface_xml(etree.fromstring(xml), struct) assert result == {"something": "This is something"} - def test_logs_on_missing_required_field(self, caplog): + def test_raises_on_missing_required_field(self, caplog): xml = """<root></root>""" struct = { "something": {"path": "./a", "transform": str, "required": True}, } - result = common.parse_interface_xml(etree.fromstring(xml), struct) - assert result is None - record = caplog.records[0] - assert record.levelname == "ERROR" - assert "required path ./a" in record.message + with pytest.raises(common.ParsingError) as e: + common.parse_interface_xml(etree.fromstring(xml), struct) + assert "required path ./a" in str(e.value) def test_logs_on_double_entry(self, caplog): xml = """<root><a>This is something</a><a>Something Else</a></root>""" @@ -76,7 +86,7 @@ class TestParseCounters: assert result == {"something": "This is something"} record = caplog.records[0] assert record.levelname == "WARNING" - assert "found more than one ./a" in record.message + assert "found more than one element ./a" in record.message def test_brian_point_counters(): @@ -160,7 +170,7 @@ def test_no_error_point_counters(): @patch.object(cli, "write_points") def test_main_for_all_juniper_routers( - write_points, mocked_get_netconf, all_juniper_routers + write_points, mocked_get_netconf, juniper_router_fqdn, juniper_inventory ): config = { "juniper": {"some": "params"}, @@ -186,9 +196,9 @@ def test_main_for_all_juniper_routers( cli.main( app_config_params=config, - router_fqdns=all_juniper_routers, + router_fqdn=juniper_router_fqdn, vendor=Vendor.JUNIPER, - raise_errors=True, + interfaces=juniper_inventory[juniper_router_fqdn], ) assert calls > 0 @@ -196,41 +206,39 @@ def test_main_for_all_juniper_routers( @pytest.fixture -def load_interfaces(): - return Mock( - return_value=[{"router": "host1"}, {"router": "host2"}, {"router": "host3"}] - ) - - -def test_validate_valid_hosts(load_interfaces): - assert cli.validate_router_hosts( - ("host1", "host2"), - vendor=Vendor.JUNIPER, - inprov_hosts=["some_host"], - load_interfaces_=load_interfaces, - ) - assert load_interfaces.called - - -def test_validate_invalid_hosts(load_interfaces): - with pytest.raises(ValueError): - cli.validate_router_hosts( - ("host1", "invalid"), - vendor=Vendor.JUNIPER, - inprov_hosts=["some_host"], - load_interfaces_=load_interfaces, - ) - assert load_interfaces.called +def mocked_load_interfaces(): + with patch.object(cli, "load_interfaces") as mock: + mock.return_value = [ + {"router": "router1", "name": "ifc1"}, + {"router": "router1", "name": "ifc2"}, + {"router": "router2", "name": "ifc3"}, + ] + yield mock + + +@patch.object(cli, "process_router") +def test_main_with_some_interfaces(process_router, mocked_load_interfaces): + config = {"juniper": {"some": "params"}, "inventory": ["some-inprov"]} + cli.main(config, "router1", Vendor.JUNIPER, interfaces=["ifc1"]) + assert process_router.call_args[1]["interfaces"] == ["ifc1"] + + +@patch.object(cli, "process_router") +def test_main_with_all_interfaces_and_inprov_hosts( + process_router, mocked_load_interfaces +): + config = {"juniper": {"some": "params"}, "inventory": ["some-inprov"]} + cli.main(config, "router1", Vendor.JUNIPER) + assert process_router.call_args[1]["interfaces"] == ["ifc1", "ifc2"] -def test_doesnt_validate_without_inprov_hosts(load_interfaces): - assert cli.validate_router_hosts( - ("host1", "invalid"), - vendor=Vendor.JUNIPER, - inprov_hosts=None, - load_interfaces_=load_interfaces, - ) - assert not load_interfaces.called +@patch.object(cli, "process_router") +def test_main_with_all_interfaces_no_inprov_hosts( + process_router, mocked_load_interfaces +): + config = {"juniper": {"some": "params"}} + cli.main(config, "router1", Vendor.JUNIPER) + assert process_router.call_args[1]["interfaces"] is None @patch.object(cli, "influx_client") @@ -273,3 +281,28 @@ def test_netconf_connect(connect): assert connect.call_args == call( host="some.router", ssh="param", other="more_params", port=830 ) + + +@patch.object(cli, "logging") +def test_setup_logging_returns_message_counter(logging): + logging.ERROR = 42 + result = cli.setup_logging() + assert isinstance(result, cli.MessageCounter) + assert result.level == logging.ERROR + + +@pytest.mark.parametrize( + "verbosity, level", + [ + (True, "DEBUG"), + (False, "INFO"), + ], +) +@patch.object(cli, "logging") +def test_setup_sets_loglevel(logging, verbosity, level): + logging.ERROR = "ERROR" + logging.INFO = "INFO" + logging.DEBUG = "DEBUG" + cli.setup_logging(verbosity) + assert logging.basicConfig.call_args[1]["level"] == level + assert logging.StreamHandler().setLevel.call_args[0][0] == level diff --git a/test/interface_stats/test_interface_stats_e2e.py b/test/interface_stats/test_interface_stats_e2e.py index 26c526f5ff2a3563cbb9a4dd8a0d7f36b3757f72..f59380f7445c178d8048622fc47d7f801051a1c4 100644 --- a/test/interface_stats/test_interface_stats_e2e.py +++ b/test/interface_stats/test_interface_stats_e2e.py @@ -245,17 +245,21 @@ def app_config_params(influx_params): } +@pytest.fixture(autouse=True) +def setup_logging(): + with patch.object(cli, "setup_logging", return_value=cli.MessageCounter()) as mock: + yield mock + + @pytest.mark.parametrize("output", iter(cli.OutputMethod)) -@patch.object(cli, "setup_logging") -@patch.object(cli, "main", return_value=0) -def test_cli_output_option( - main, unused_setup_logging, app_config_filename, output, all_juniper_routers -): +@patch.object(cli, "main") +def test_cli_output_option(main, app_config_filename, output, all_juniper_routers): cli_args = [ "--config", app_config_filename, "--output", output.value, + "--all", "--juniper", all_juniper_routers[0], ] @@ -317,9 +321,7 @@ def verify_influx_content( @pytest.mark.skipif( not _use_docker_compose(), reason="docker compose not found or disabled" ) -@patch.object(cli, "setup_logging") def test_e2e_juniper( - unused_setup_logging, mocked_get_netconf, app_config_params: Dict[str, Any], app_config_filename: str, @@ -330,16 +332,17 @@ def test_e2e_juniper( load all router interfaces into a tmp influx container, check that all potential counter fields are populated """ - - cli_args = [ - "--config", - app_config_filename, - "--juniper", - *all_juniper_routers, - ] - runner = CliRunner() - result = runner.invoke(cli.cli, cli_args) - assert result.exit_code == 0, str(result) + for router in all_juniper_routers: + cli_args = [ + "--config", + app_config_filename, + "--all", + "--juniper", + router, + ] + runner = CliRunner() + result = runner.invoke(cli.cli, cli_args) + assert result.exit_code == 0, str(result) verify_influx_content( influx_config=app_config_params["influx"]["brian-counters"], @@ -356,9 +359,7 @@ def test_e2e_juniper( @pytest.mark.skipif( not _use_docker_compose(), reason="docker compose not found or disabled" ) -@patch.object(cli, "setup_logging") def test_e2e_nokia( - unused_setup_logging, mocked_get_netconf, app_config_params: Dict[str, Any], app_config_filename: str, @@ -369,16 +370,17 @@ def test_e2e_nokia( load all router interfaces into a tmp influx container, check that all potential counter fields are populated """ - - cli_args = [ - "--config", - app_config_filename, - "--nokia", - *all_nokia_routers, - ] - runner = CliRunner() - result = runner.invoke(cli.cli, cli_args) - assert result.exit_code == 0, str(result) + for router in all_nokia_routers: + cli_args = [ + "--config", + app_config_filename, + "--all", + "--nokia", + router, + ] + runner = CliRunner() + result = runner.invoke(cli.cli, cli_args) + assert result.exit_code == 0, str(result) verify_influx_content( influx_config=app_config_params["influx"]["brian-counters"], diff --git a/test/interface_stats/test_juniper.py b/test/interface_stats/test_juniper.py index 90567e5abf9b5251a137a93b2d29449dcd74c80a..8f8096fca94a8739dee99888e3de7c1074073561 100644 --- a/test/interface_stats/test_juniper.py +++ b/test/interface_stats/test_juniper.py @@ -1,6 +1,4 @@ from datetime import datetime -import itertools -import re from unittest.mock import call, patch import jsonschema @@ -89,115 +87,103 @@ def juniper_router_doc_containing_every_field(): def test_verify_all_interfaces_present( - juniper_router_fqdn, polled_interfaces, get_netconf + juniper_router_fqdn, juniper_inventory, get_netconf ): """ - verify that all the interfaces we expect to poll - are available in the netconf data - compares a snapshot of all netconf docs with a - a snapshot of inventory /poller/interfaces - (the snapshots were all taken around the same time) + verify that all the interfaces we expect to poll are available in the netconf data + compares a snapshot of all netconf docs with a a snapshot of inventory + /poller/interfaces (the snapshots were all taken around the same time) """ - def _is_enabled(ifc_name, ifc_doc): - m = re.match(r"^([^\.]+)\.?.*", ifc_name) - assert m # sanity: should never fail - phy = ifc_doc.xpath( - f'//interface-information/physical-interface[normalize-space(name)="{m.group(1)}"]' - )[0] - admin_status = phy.xpath("./admin-status/text()")[0].strip() - oper_status = phy.xpath("./oper-status/text()")[0].strip() - - return admin_status == "up" and oper_status == "up" - - if juniper_router_fqdn not in polled_interfaces: + if juniper_router_fqdn not in juniper_inventory: pytest.skip(f"{juniper_router_fqdn} has no expected polled interfaces") - + all_interfaces = juniper_inventory[juniper_router_fqdn] doc = get_netconf(juniper_router_fqdn) - phy = juniper._physical_interface_counters(doc) - log = juniper._logical_interface_counters(doc) - interfaces = set(x["name"] for x in itertools.chain(phy, log)) - missing_interfaces = polled_interfaces[juniper_router_fqdn] - interfaces - for ifc_name in missing_interfaces: - # verify that any missing interfaces are admin/oper disabled - assert not _is_enabled(ifc_name, doc) + counters = juniper.interface_counters(doc, interfaces=all_interfaces) + interfaces = {x["name"] for x in counters} + missing = juniper_inventory[juniper_router_fqdn] - interfaces + assert not missing -def test_physical_interface_counters(juniper_router_doc_containing_every_field): +def test_interface_counters(juniper_router_doc_containing_every_field): result = list( - juniper._physical_interface_counters(juniper_router_doc_containing_every_field) + juniper.interface_counters( + juniper_router_doc_containing_every_field, interfaces=["ae12", "ae12.1"] + ) ) - assert len(result) == 1 - assert result[0] == { - "name": "ae12", - "brian": { - "ingressOctets": 1, - "ingressPackets": 2, - "egressOctets": 3, - "egressPackets": 4, - "ingressOctetsv6": 5, - "ingressPacketsv6": 6, - "egressOctetsv6": 7, - "egressPacketsv6": 8, - "ingressErrors": 11, - "ingressDiscards": 12, - "egressErrors": 21, + assert result == [ + { + "name": "ae12", + "brian": { + "ingressOctets": 1, + "ingressPackets": 2, + "egressOctets": 3, + "egressPackets": 4, + "ingressOctetsv6": 5, + "ingressPacketsv6": 6, + "egressOctetsv6": 7, + "egressPacketsv6": 8, + "ingressErrors": 11, + "ingressDiscards": 12, + "egressErrors": 21, + }, + "errors": { + "input_discards": 12, + "input_fifo_errors": 13, + "input_drops": 14, + "input_framing_errors": 15, + "input_resource_errors": 16, + "output_drops": 22, + "output_resource_errors": 23, + "output_fifo_errors": 24, + "output_collisions": 25, + "output_discards": 26, + "input_crc_errors": 31, + "output_crc_errors": 32, + "input_total_errors": 33, + "output_total_errors": 34, + "bit_error_seconds": 41, + "errored_blocks_seconds": 42, + }, }, - "errors": { - "input_discards": 12, - "input_fifo_errors": 13, - "input_drops": 14, - "input_framing_errors": 15, - "input_resource_errors": 16, - "output_drops": 22, - "output_resource_errors": 23, - "output_fifo_errors": 24, - "output_collisions": 25, - "output_discards": 26, - "input_crc_errors": 31, - "output_crc_errors": 32, - "input_total_errors": 33, - "output_total_errors": 34, - "bit_error_seconds": 41, - "errored_blocks_seconds": 42, + { + "name": "ae12.1", + "brian": { + "ingressOctets": 51, + "ingressPackets": 52, + "egressOctets": 53, + "egressPackets": 54, + "ingressOctetsv6": 55, + "ingressPacketsv6": 56, + "egressOctetsv6": 57, + "egressPacketsv6": 58, + }, }, - } - - -def test_logical_interface_counters(juniper_router_doc_containing_every_field): - result = list( - juniper._logical_interface_counters(juniper_router_doc_containing_every_field) - ) - assert len(result) == 1 - assert result[0] == { - "name": "ae12.1", - "brian": { - "ingressOctets": 51, - "ingressPackets": 52, - "egressOctets": 53, - "egressPackets": 54, - "ingressOctetsv6": 55, - "ingressPacketsv6": 56, - "egressOctetsv6": 57, - "egressPacketsv6": 58, - }, - } + ] def test_juniper_router_docs_do_not_generate_errors( - get_netconf, juniper_router_fqdn, caplog + get_netconf, juniper_router_fqdn, caplog, juniper_inventory ): doc = get_netconf(juniper_router_fqdn) - counters = list(juniper.interface_counters(doc)) + counters = list( + juniper.interface_counters( + doc, interfaces=juniper_inventory[juniper_router_fqdn] + ) + ) assert counters assert not [r for r in caplog.records if r.levelname in ("ERROR", "WARNING")] def test_validate_interface_counters_and_influx_points_for_all_juniper_routers( - juniper_router_fqdn, get_netconf + juniper_router_fqdn, get_netconf, juniper_inventory ): doc = get_netconf(juniper_router_fqdn) - interfaces = list(juniper.interface_counters(doc)) + interfaces = list( + juniper.interface_counters( + doc, interfaces=juniper_inventory[juniper_router_fqdn] + ) + ) assert interfaces for ifc in interfaces: jsonschema.validate(ifc, common.INTERFACE_COUNTER_SCHEMA) diff --git a/test/interface_stats/test_nokia.py b/test/interface_stats/test_nokia.py index fbe8a1f998d73fbaae4e1c44ef52466f6d6cea32..28e3dc4fe333215e47fc91506e2986626f17388b 100644 --- a/test/interface_stats/test_nokia.py +++ b/test/interface_stats/test_nokia.py @@ -90,75 +90,74 @@ def nokia_router_interface_doc_containing_every_field(): return etree.fromstring(NOKIA_ROUTER_INTERFACE_XML) -def test_port_counters(nokia_port_doc_containing_every_field): - result = list(nokia._port_counters(nokia_port_doc_containing_every_field)) - assert len(result) == 1 - assert result[0] == { - "name": "1/1/c1", - "brian": { - "ingressOctets": 1, - "ingressPackets": 2, - "egressOctets": 3, - "egressPackets": 4, - "ingressErrors": 5, - "ingressDiscards": 6, - "egressErrors": 7, - }, - "errors": { - "input_total_errors": 5, - "input_discards": 6, - "output_total_errors": 7, - "output_discards": 8, - }, +def nokia_docs_containing_every_field(): + return { + "port": etree.fromstring(NOKIA_PORT_XML), + "lag": etree.fromstring( + NOKIA_PORT_XML.replace("port>", "lag>").replace( + "<port-id>1/1/c1</port-id>", "<lag-name>lag-1</lag-name>" + ) + ), + "router-interface": etree.fromstring(NOKIA_ROUTER_INTERFACE_XML), } -def test_lag_counters(nokia_lag_doc_containing_every_field): - result = list(nokia._lag_counters(nokia_lag_doc_containing_every_field)) - assert len(result) == 1 - assert result[0] == { - "name": "lag-1", - "brian": { - "ingressOctets": 1, - "ingressPackets": 2, - "egressOctets": 3, - "egressPackets": 4, - "ingressErrors": 5, - "ingressDiscards": 6, - "egressErrors": 7, +def test_nokia_counters(): + result = list(nokia.interface_counters(nokia_docs_containing_every_field())) + assert result == [ + { + "name": "1/1/c1", + "brian": { + "ingressOctets": 1, + "ingressPackets": 2, + "egressOctets": 3, + "egressPackets": 4, + "ingressErrors": 5, + "ingressDiscards": 6, + "egressErrors": 7, + }, + "errors": { + "input_total_errors": 5, + "input_discards": 6, + "output_total_errors": 7, + "output_discards": 8, + }, }, - "errors": { - "input_total_errors": 5, - "input_discards": 6, - "output_total_errors": 7, - "output_discards": 8, + { + "name": "lag-1", + "brian": { + "ingressOctets": 1, + "ingressPackets": 2, + "egressOctets": 3, + "egressPackets": 4, + "ingressErrors": 5, + "ingressDiscards": 6, + "egressErrors": 7, + }, + "errors": { + "input_total_errors": 5, + "input_discards": 6, + "output_total_errors": 7, + "output_discards": 8, + }, }, - } - - -def test_router_interface_counters(nokia_router_interface_doc_containing_every_field): - result = list( - nokia._router_interface_counters( - nokia_router_interface_doc_containing_every_field - ) - ) - assert len(result) == 1 - assert result[0] == { - "name": "lag-1.0", - "brian": { - "ingressOctets": 11, - "ingressPackets": 12, - "egressOctets": 13, - "egressPackets": 14, - "ingressOctetsv6": 16, - "ingressPacketsv6": 17, - "egressOctetsv6": 18, - "egressPacketsv6": 19, + { + "name": "lag-1.0", + "brian": { + "ingressOctets": 11, + "ingressPackets": 12, + "egressOctets": 13, + "egressPackets": 14, + "ingressOctetsv6": 16, + "ingressPacketsv6": 17, + "egressOctetsv6": 18, + "egressPacketsv6": 19, + }, + "errors": { + "output_discards": 15, + }, }, - "errors": { - "output_discards": 15, - }, - } + ] def test_nokia_router_docs_do_not_generate_errors( @@ -206,6 +205,15 @@ def test_validate_interface_counters_and_influx_points_for_all_nokia_routers( jsonschema.validate(point["fields"], common.ERROR_POINT_FIELDS_SCHEMA) +def test_processes_specific_interfaces(get_netconf, caplog): + doc = get_netconf("rt0.lon.uk.lab.office.geant.net") + interfaces = ["1/1/c1", "lag-1", "lag-1.0"] + result = list(nokia.interface_counters(doc, interfaces=interfaces)) + assert len(result) == 3 + assert all(isinstance(i, dict) for i in result) + assert not [r for r in caplog.records if r.levelname in ("ERROR", "WARNING")] + + class TestGetNokiaNetconf: RAW_RESPONSE_FILE = "raw-response-nokia-sample.xml" @@ -240,7 +248,9 @@ class TestGetNokiaNetconf: "filter", "{urn:nokia.com:sros:ns:yang:sr:state}state", "{urn:nokia.com:sros:ns:yang:sr:state}" + kind, + "{urn:nokia.com:sros:ns:yang:sr:state}statistics", ] + elems = calls[2][1]["filter"].iter() assert [e.tag for e in elems] == [ "filter", @@ -258,3 +268,4 @@ class TestGetNokiaNetconf: assert doc["port"].tag == "rpc-reply" assert doc["lag"].tag == "rpc-reply" + assert doc["router-interface"].tag == "rpc-reply"