From 8b42707fc35a895aecf5cb18d64141e73cc74e5f Mon Sep 17 00:00:00 2001
From: Pelle Koster <pelle.koster@geant.org>
Date: Wed, 24 Apr 2024 06:34:55 +0000
Subject: [PATCH] Feature/POL1-799 get-interface-stats processes selected
 interfaces given through cli args or uses inventory provider

---
 brian_polling_manager/interface_stats/cli.py  | 228 ++++++++++--------
 .../interface_stats/config.py                 |   3 +-
 .../interface_stats/vendors/common.py         |  96 +++++++-
 .../interface_stats/vendors/juniper.py        |  64 ++---
 .../interface_stats/vendors/nokia.py          | 122 +++++-----
 test/interface_stats/conftest.py              |  12 +-
 test/interface_stats/test_interface_stats.py  | 131 ++++++----
 .../test_interface_stats_e2e.py               |  60 ++---
 test/interface_stats/test_juniper.py          | 160 ++++++------
 test/interface_stats/test_nokia.py            | 137 ++++++-----
 10 files changed, 571 insertions(+), 442 deletions(-)

diff --git a/brian_polling_manager/interface_stats/cli.py b/brian_polling_manager/interface_stats/cli.py
index 8783169..196cb94 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 5616584..0b8ead0 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 91e08dc..be46556 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 2dd6141..000ab13 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 a4179ad..8025652 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 ed0a766..82659a6 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 d87cfc2..8567b73 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 26c526f..f59380f 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 90567e5..8f8096f 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 fbe8a1f..28e3dc4 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"
-- 
GitLab