Skip to content
Snippets Groups Projects
Commit 8b42707f authored by Pelle Koster's avatar Pelle Koster
Browse files

Feature/POL1-799 get-interface-stats processes selected interfaces given...

Feature/POL1-799 get-interface-stats processes selected interfaces given through cli args or uses inventory provider
parent f0755024
No related branches found
No related tags found
No related merge requests found
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:
# 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
)
else:
interfaces = None
process_router(
router_fqdn=router,
router_fqdn=router_fqdn,
vendor=vendor,
interfaces=interfaces,
app_config_params=app_config_params,
output=output,
)
except Exception:
logger.exception(
f"Error while processing {vendor_str.capitalize()} router {router}"
)
error_count += 1
if raise_errors:
raise
return error_count
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(
try:
main(
app_config_params=app_config_params,
router_fqdns=router_fqdn,
router_fqdn=router_fqdn,
vendor=vendor,
output=OutputMethod(output.lower()),
interfaces=interfaces if interfaces else ALL_,
)
if error_count:
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"
)
......
......
......@@ -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"},
......
......
import contextlib
import logging
from functools import partial
from typing import Set
import ncclient.manager
from lxml import etree
......@@ -142,27 +143,102 @@ 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)
if parsed is None:
continue
......@@ -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
......
......
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):
......
......
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,13 +67,33 @@ 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
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
:returns: a dictionary with doctype (``port``, ``lag`` & ``router-interface`` ) as
keys and their respective netconf document as values
"""
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 {
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(
......@@ -79,21 +101,8 @@ def get_netconf_interface_info(router_name: str, ssh_params: dict):
)
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"),
}
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 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_xml(state_doc: etree.Element):
return state_doc.xpath("//data/state/router/interface")
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"
def interface_counters(
raw_counter_docs: dict, interfaces: Optional[Sequence[str]] = None
):
"""
for elem in state_doc.xpath("//data/state/router/interface"):
yield parse_interface_xml(elem, INTERFACE_COUNTERS_ALT)
def interface_counters(raw_counter_docs: dict):
"""
: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")
......@@ -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,10 +53,14 @@ 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"]:
if _excluded(ifc):
continue
polled.setdefault(ifc["router"], set()).add(ifc["name"])
return polled
......
......
......@@ -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 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
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
@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"]
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
@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
......@@ -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,12 +332,13 @@ def test_e2e_juniper(
load all router interfaces into a tmp influx container, check that
all potential counter fields are populated
"""
for router in all_juniper_routers:
cli_args = [
"--config",
app_config_filename,
"--all",
"--juniper",
*all_juniper_routers,
router,
]
runner = CliRunner()
result = runner.invoke(cli.cli, cli_args)
......@@ -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,12 +370,13 @@ def test_e2e_nokia(
load all router interfaces into a tmp influx container, check that
all potential counter fields are populated
"""
for router in all_nokia_routers:
cli_args = [
"--config",
app_config_filename,
"--all",
"--nokia",
*all_nokia_routers,
router,
]
runner = CliRunner()
result = runner.invoke(cli.cli, cli_args)
......
......
from datetime import datetime
import itertools
import re
from unittest.mock import call, patch
import jsonschema
......@@ -89,46 +87,32 @@ 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] == {
assert result == [
{
"name": "ae12",
"brian": {
"ingressOctets": 1,
......@@ -161,15 +145,8 @@ def test_physical_interface_counters(juniper_router_doc_containing_every_field):
"bit_error_seconds": 41,
"errored_blocks_seconds": 42,
},
}
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,
......@@ -181,23 +158,32 @@ def test_logical_interface_counters(juniper_router_doc_containing_every_field):
"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)
......
......
......@@ -90,10 +90,22 @@ 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] == {
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_nokia_counters():
result = list(nokia.interface_counters(nokia_docs_containing_every_field()))
assert result == [
{
"name": "1/1/c1",
"brian": {
"ingressOctets": 1,
......@@ -110,13 +122,8 @@ def test_port_counters(nokia_port_doc_containing_every_field):
"output_total_errors": 7,
"output_discards": 8,
},
}
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,
......@@ -133,17 +140,8 @@ def test_lag_counters(nokia_lag_doc_containing_every_field):
"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,
......@@ -158,7 +156,8 @@ def test_router_interface_counters(nokia_router_interface_doc_containing_every_f
"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"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment