diff --git a/device-discovery/custom_napalm/iosxr.py b/device-discovery/custom_napalm/iosxr.py index 00c33acb..a5a55261 100644 --- a/device-discovery/custom_napalm/iosxr.py +++ b/device-discovery/custom_napalm/iosxr.py @@ -247,9 +247,123 @@ def _iosxr_get_modules_impl(driver) -> dict | None: }) +# "show vrf all detail" block headers: VRF ; RD ; VPN ID . +_IOSXR_VRF_HEADER_RE = re.compile(r"^\s*VRF (\S+); RD (.+?); VPN ID") +# Member rows under the "Interfaces:" section — one indented ifname per line, +# same character class the cisco_xr ntc-template uses for interface names. +_IOSXR_VRF_IFACE_RE = re.compile(r"^\s+([\w./-]+)\s*$") +_IOSXR_VRF_IFACES_HDR_RE = re.compile(r"^\s*Interfaces:\s*$") + + +def _iosxr_parse_vrf_blocks(raw: str) -> list[dict]: + """ + Driver-local parse of "show vrf all detail" into name/rd/interfaces rows. + + Deliberately NOT the cisco_xr ntc-template: its FSM only leaves the + Interfaces / route-target states on specific follow-up lines ("Address + family ...", "No import route policy"), so a VRF block without an + address family, or with an import/export route policy attached + (`Import route policy: RPL_IN` — routine in production L3VPN), + swallows the NEXT VRF's header and misattributes its interfaces. + A line-stateful walk keyed on the unambiguous block header has no + such stuck states: interface collection starts at "Interfaces:" and + stops at the first line that isn't a lone indented interface name. + """ + rows: list[dict] = [] + current: dict | None = None + in_interfaces = False + for line in raw.splitlines(): + m = _IOSXR_VRF_HEADER_RE.match(line) + if m: + current = {"vrf": m.group(1), "rd": m.group(2), "interfaces": []} + rows.append(current) + in_interfaces = False + continue + if current is None: + continue + if _IOSXR_VRF_IFACES_HDR_RE.match(line): + in_interfaces = True + continue + if in_interfaces: + m = _IOSXR_VRF_IFACE_RE.match(line) + if m: + current["interfaces"].append(m.group(1)) + else: + in_interfaces = False + return rows + + +def _iosxr_default_instance() -> dict: + """ + Return the DEFAULT_INSTANCE entry for the global routing table. + + Its interface membership is intentionally left empty: enumerating + default-table interfaces needs a second command, and the discovery + pipeline only consumes VRF (L3VRF) memberships — every interface not + claimed by a VRF is in the default table by definition. + """ + return { + "name": "default", + "type": "DEFAULT_INSTANCE", + "state": {"route_distinguisher": ""}, + "interfaces": {"interface": {}}, + } + + +def _iosxr_get_network_instances_impl(driver, name: str = "") -> dict: + """ + VRF discovery for IOS-XR via pyIOSXR ("show vrf all detail"). + + Parsed driver-locally (see _iosxr_parse_vrf_blocks for why the + cisco_xr ntc-template is not used); each row carries the VRF name, + its route distinguisher (the literal "not set" when unconfigured — + normalized to ""), and the member interface list. + """ + try: + raw = driver.device._execute_show("show vrf all detail") + except (ConnectError, IOSXRTimeoutError, InvalidInputError, XMLCLIError) as e: + logger.warning("iosxr.get_network_instances: show vrf all detail failed: %s", e) + # Deliberately {} (not the seeded default instance): a transport + # failure means the device state is unknown, and an empty dict is + # the unambiguous "discovery failed" signal. The seeded default is + # returned only on paths where the device DID respond — there the + # default table is a platform invariant, not fabricated knowledge. + return {} + + instances: dict = {"default": _iosxr_default_instance()} + rows = _iosxr_parse_vrf_blocks(raw) if raw else [] + for row in rows: + vrf = (row.get("vrf") or "").strip() + # Never let a parsed row overwrite the seeded DEFAULT_INSTANCE — + # a row named "default" is the global table, not an L3VRF. + if not vrf or vrf == "default": + continue + rd = (row.get("rd") or "").strip() + if rd.lower() == "not set": + rd = "" + interfaces = { + ifname.strip(): {} + for ifname in (row.get("interfaces") or []) + if ifname and ifname.strip() + } + instances[vrf] = { + "name": vrf, + "type": "L3VRF", + "state": {"route_distinguisher": rd}, + "interfaces": {"interface": interfaces}, + } + if name: + return {name: instances[name]} if name in instances else {} + return instances + + class IOSXRDriver(_UpstreamIOSXRDriver): """Custom IOS-XR driver shim adding get_modules() to the upstream class.""" def get_modules(self) -> dict | None: """Return per-rack module / module-bay inventory or None.""" return _iosxr_get_modules_impl(self) + + def get_network_instances(self, name: str = "") -> dict: + """Return network instances (VRFs) keyed by name, NAPALM OC shape.""" + return _iosxr_get_network_instances_impl(self, name) diff --git a/device-discovery/custom_napalm/iosxr_netconf.py b/device-discovery/custom_napalm/iosxr_netconf.py index 429cb165..bd4b39c1 100644 --- a/device-discovery/custom_napalm/iosxr_netconf.py +++ b/device-discovery/custom_napalm/iosxr_netconf.py @@ -39,6 +39,11 @@ _INVMGR_NS = "http://cisco.com/ns/yang/Cisco-IOS-XR-invmgr-oper" _NS_MAP = {"imo": _INVMGR_NS} # prefix matches upstream napalm.iosxr_netconf convention +# NETCONF replies are untrusted device input — disable entity resolution +# and network access so a hostile payload can't XXE local files. Used by +# every XML parse in this driver. +_SAFE_XML_PARSER = ETREE.XMLParser(resolve_entities=False, no_network=True) + # Focused subtree filter: just the entity name + inv-basic-bag fields we # need. Empty leaf elements ("") mean "select all instances". Mirrors # the upstream FACTS_RPC_REQ pattern in napalm/iosxr_netconf/constants.py. @@ -118,7 +123,10 @@ def _iosxr_netconf_rows_from_xml(xml_text: str) -> list[dict]: if not xml_text: return [] try: - root = ETREE.fromstring(xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text) + root = ETREE.fromstring( + xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text, + parser=_SAFE_XML_PARSER, + ) except ETREE.XMLSyntaxError as e: logger.warning("iosxr_netconf.get_modules: XML parse failed: %s", e) return [] @@ -257,9 +265,113 @@ def _iosxr_netconf_get_modules_impl(driver) -> dict | None: }) +_MPLS_VPN_NS = "http://cisco.com/ns/yang/Cisco-IOS-XR-mpls-vpn-oper" +_VPN_NS_MAP = {"mvo": _MPLS_VPN_NS} + +# Focused subtree filter on the L3VPN operational model — the same data +# source "show vrf all detail" renders. One RPC returns every VRF with its +# route distinguisher and member interface list. +_VRF_FILTER = f""" + + + + + + + + + + + +""" + + +def _iosxr_netconf_default_instance() -> dict: + """ + Return the DEFAULT_INSTANCE entry for the global routing table. + + Interface membership is intentionally left empty — the discovery + pipeline only consumes VRF (L3VRF) memberships, and every interface + not claimed by a VRF is in the default table by definition. + """ + return { + "name": "default", + "type": "DEFAULT_INSTANCE", + "state": {"route_distinguisher": ""}, + "interfaces": {"interface": {}}, + } + + +def _iosxr_netconf_filter_instances(instances: dict, name: str) -> dict: + """Apply the NAPALM name-filter contract on every return path.""" + if name: + return {name: instances[name]} if name in instances else {} + return instances + + +def _iosxr_netconf_get_network_instances_impl(driver, name: str = "") -> dict: + """ + VRF discovery for IOS-XR via NETCONF (Cisco-IOS-XR-mpls-vpn-oper). + + Walks l3vpn/vrfs/vrf entries to the same OC shape the SSH driver + produces from "show vrf all detail". An unconfigured RD surfaces as + the literal "not set" (mirroring the CLI rendering) or empty — + both normalize to "". + """ + try: + reply = driver.device.get(filter=("subtree", _VRF_FILTER)) + except NCClientError as e: + logger.warning("iosxr_netconf.get_network_instances: NETCONF get failed: %s", e) + # Deliberately {} (not the seeded default instance): a transport + # failure means the device state is unknown, and an empty dict is + # the unambiguous "discovery failed" signal. The seeded default is + # returned only on paths where the device DID respond — there the + # default table is a platform invariant, not fabricated knowledge. + return {} + xml_text = getattr(reply, "xml", None) or getattr(reply, "data_xml", None) or "" + instances: dict = {"default": _iosxr_netconf_default_instance()} + if not xml_text: + return _iosxr_netconf_filter_instances(instances, name) + try: + root = ETREE.fromstring( + xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text, + parser=_SAFE_XML_PARSER, + ) + except ETREE.XMLSyntaxError as e: + logger.warning("iosxr_netconf.get_network_instances: XML parse failed: %s", e) + return _iosxr_netconf_filter_instances(instances, name) + for vrf_el in root.findall(".//mvo:l3vpn/mvo:vrfs/mvo:vrf", _VPN_NS_MAP): + name_el = vrf_el.find("mvo:vrf-name", _VPN_NS_MAP) + vrf_name = (name_el.text or "").strip() if name_el is not None else "" + # Never let a model row overwrite the seeded DEFAULT_INSTANCE — + # a row named "default" is the global table, not an L3VRF. + if not vrf_name or vrf_name == "default": + continue + rd_el = vrf_el.find("mvo:route-distinguisher", _VPN_NS_MAP) + rd = (rd_el.text or "").strip() if rd_el is not None else "" + if rd.lower() == "not set": + rd = "" + interfaces: dict = {} + for if_el in vrf_el.findall("mvo:interface/mvo:interface-name", _VPN_NS_MAP): + ifname = (if_el.text or "").strip() + if ifname: + interfaces[ifname] = {} + instances[vrf_name] = { + "name": vrf_name, + "type": "L3VRF", + "state": {"route_distinguisher": rd}, + "interfaces": {"interface": interfaces}, + } + return _iosxr_netconf_filter_instances(instances, name) + + class IOSXRNETCONFDriver(_UpstreamIOSXRNetconfDriver): """Custom IOS-XR NETCONF driver shim adding get_modules().""" def get_modules(self) -> dict | None: """Return per-rack module / module-bay inventory or None.""" return _iosxr_netconf_get_modules_impl(self) + + def get_network_instances(self, name: str = "") -> dict: + """Return network instances (VRFs) keyed by name, NAPALM OC shape.""" + return _iosxr_netconf_get_network_instances_impl(self, name) diff --git a/device-discovery/custom_napalm/nokia_srl.py b/device-discovery/custom_napalm/nokia_srl.py index c9d12d3c..87e13312 100644 --- a/device-discovery/custom_napalm/nokia_srl.py +++ b/device-discovery/custom_napalm/nokia_srl.py @@ -185,6 +185,48 @@ def _strip_prompt(text: str) -> str: return _SRL_PROMPT_RE.sub("", text).rstrip() +# SR Linux network-instance types → NAPALM OC network-instance types. +# Unknown types pass through raw so the consumer can decide. +_SRL_NI_TYPE_MAP = { + "ip-vrf": "L3VRF", + "default": "DEFAULT_INSTANCE", + "mac-vrf": "L2VSI", +} + +# Block headers in `info from state network-instance ...` output. Names may +# be quoted when they contain spaces; the value group strips the quotes. +_SRL_NI_HEADER_RE = re.compile(r'^\s*network-instance\s+"?([^"{\s][^"{]*?)"?\s*\{\s*$') +# Optional module prefix (srl_nokia-network-instance:ip-vrf) tolerated and +# stripped — only the short identity name is captured. +_SRL_NI_TYPE_RE = re.compile(r'^\s*type\s+"?(?:[\w.-]+:)?([\w-]+)"?\s*$') +_SRL_NI_IFACE_RE = re.compile(r'^\s*interface\s+"?([^"{\s][^"{]*?)"?\s*\{\s*$') +_SRL_NI_RD_RE = re.compile(r'^\s*rd\s+"?(\S+?)"?\s*$') + + +def _parse_ni_blocks(text: str, line_re: re.Pattern) -> dict[str, list[str]]: + """ + Collect per-network-instance matches from `info from state` block output. + + The output nests everything under ``network-instance {`` block + headers; this walks line-by-line, tracks the current instance, and + records each ``line_re`` group(1) hit against it. + """ + out: dict[str, list[str]] = {} + current: str | None = None + for line in (text or "").splitlines(): + m = _SRL_NI_HEADER_RE.match(line) + if m: + current = m.group(1).strip() + out.setdefault(current, []) + continue + if current is None: + continue + m = line_re.match(line) + if m: + out[current].append(m.group(1).strip()) + return out + + def _make_intf_entry(m) -> dict: reason = m.group(3) return { @@ -508,3 +550,54 @@ def get_config( def get_vlans(self) -> dict: """SR Linux uses network instances, not traditional VLANs.""" return {} + + def get_network_instances(self, name: str = "") -> dict: + """ + Return network instances keyed by name, NAPALM OC shape. + + Network instances are SR Linux's native routing model. Three + targeted ``info from state`` calls keep the payloads small: + instance types, interface membership, and the BGP-VPN route + distinguisher (only present on instances participating in an + EVPN / IP-VPN backbone). Type mapping: ``ip-vrf`` → L3VRF, + ``default`` → DEFAULT_INSTANCE, ``mac-vrf`` → L2VSI; unknown + types pass through raw. + """ + type_out = self.device.send_command( + "info from state network-instance * type" + ) + if not type_out or not type_out.strip(): + return {} + types = _parse_ni_blocks(type_out, _SRL_NI_TYPE_RE) + iface_out = self.device.send_command( + "info from state network-instance * interface * name" + ) + ifaces = _parse_ni_blocks(iface_out, _SRL_NI_IFACE_RE) + rd_out = self.device.send_command( + "info from state network-instance * protocols bgp-vpn " + "bgp-instance * route-distinguisher rd" + ) + rds = _parse_ni_blocks(rd_out, _SRL_NI_RD_RE) + + instances: dict = {} + for ni_name, type_hits in types.items(): + raw_type = type_hits[0] if type_hits else "" + ni_type = _SRL_NI_TYPE_MAP.get(raw_type, raw_type) + rd_hits = rds.get(ni_name) or [] + # Default-instance membership is left empty like the other + # batch drivers: the discovery pipeline only consumes VRF + # memberships, and every interface not claimed by a VRF is + # in the default table by definition. + if ni_type == "DEFAULT_INSTANCE": + members: dict = {} + else: + members = {ifname: {} for ifname in ifaces.get(ni_name) or []} + instances[ni_name] = { + "name": ni_name, + "type": ni_type, + "state": {"route_distinguisher": rd_hits[0] if rd_hits else ""}, + "interfaces": {"interface": members}, + } + if name: + return {name: instances[name]} if name in instances else {} + return instances diff --git a/device-discovery/custom_napalm/nokia_sros.py b/device-discovery/custom_napalm/nokia_sros.py index a0475ec4..77d5b243 100644 --- a/device-discovery/custom_napalm/nokia_sros.py +++ b/device-discovery/custom_napalm/nokia_sros.py @@ -254,6 +254,28 @@ """ +# Subtree filter for VRF (VPRN service) discovery. One RPC returns every +# VPRN with its route distinguisher (bgp-ipvpn/mpls) and member interfaces. +_FILTER_NETWORK_INSTANCES = f""" + + + + + + + + + + + + + + + + + +""" + # --------------------------------------------------------------------------- # Config sanitization — Nokia SR-OS YANG XML sensitive element content # --------------------------------------------------------------------------- @@ -742,6 +764,89 @@ def _nokia_sros_get_modules_impl(driver) -> dict | None: }) +def _nokia_sros_filter_instances(instances: dict, name: str) -> dict: + """Apply the NAPALM name-filter contract on every return path.""" + if name: + return {name: instances[name]} if name in instances else {} + return instances + + +def _nokia_sros_get_network_instances_impl(driver, name: str = "") -> dict: + """ + VRF discovery for Nokia SR-OS via NETCONF: VPRN services as L3VRFs. + + SR OS models VRFs as VPRN services — each maps to a NetBox VRF, with + member interfaces keyed "/" to match how + get_interfaces_ip() names them. The Base router is the global routing + table and is emitted as the DEFAULT_INSTANCE with empty membership: + the discovery pipeline only consumes VRF memberships, and interfaces + of the Base and management router contexts (which get_interfaces_ip + keys "/" for non-Base routers) are deliberately + not claimed by any VRF. The RD lives at vprn/bgp-ipvpn/mpls and is + absent on VPRNs without an MPLS L3VPN backbone. + """ + instances: dict = { + "Base": { + "name": "Base", + "type": "DEFAULT_INSTANCE", + "state": {"route_distinguisher": ""}, + "interfaces": {"interface": {}}, + }, + } + try: + reply = driver.conn.get(filter=_FILTER_NETWORK_INSTANCES) + except NCClientError as e: + logger.warning("nokia_sros.get_network_instances: VPRN RPC failed: %s", e) + # Deliberately {} (not the seeded default instance): a transport + # failure means the device state is unknown, and an empty dict is + # the unambiguous "discovery failed" signal. The seeded default is + # returned only on paths where the device DID respond — there the + # default table is a platform invariant, not fabricated knowledge. + return {} + if getattr(reply, "data_xml", None) is None: + logger.warning( + "nokia_sros.get_network_instances: VPRN RPC returned empty data_xml" + ) + return _nokia_sros_filter_instances(instances, name) + try: + root = _parse_xml(reply.data_xml) + except etree.XMLSyntaxError as e: + logger.warning("nokia_sros.get_network_instances: XML parse failed: %s", e) + return _nokia_sros_filter_instances(instances, name) + for vprn in root.findall( + ".//configure_ns:service/configure_ns:vprn", _NSMAP, + ): + svc_el = vprn.find("configure_ns:service-name", _NSMAP) + svc_name = (svc_el.text or "").strip() if svc_el is not None else "" + # Never let a VPRN row overwrite the seeded DEFAULT_INSTANCE — + # "Base" is the global routing table, not an L3VRF. Mirrors the + # IOS-XR drivers' guard against rows named "default". + if not svc_name or svc_name == "Base": + continue + rd_el = vprn.find( + "configure_ns:bgp-ipvpn/configure_ns:mpls/configure_ns:route-distinguisher", + _NSMAP, + ) + rd = (rd_el.text or "").strip() if rd_el is not None else "" + interfaces: dict = {} + for if_el in vprn.findall( + "configure_ns:interface/configure_ns:interface-name", _NSMAP, + ): + ifname = (if_el.text or "").strip() + if ifname: + # Keyed "/" to match how + # get_interfaces_ip() names VPRN interfaces — downstream + # VRF attachment joins on exact interface names. + interfaces[f"{svc_name}/{ifname}"] = {} + instances[svc_name] = { + "name": svc_name, + "type": "L3VRF", + "state": {"route_distinguisher": rd}, + "interfaces": {"interface": interfaces}, + } + return _nokia_sros_filter_instances(instances, name) + + class SROSDriver(_napalm_base.NetworkDriver): """Nokia SR-OS NAPALM driver using NETCONF/YANG (read-only subset for device-discovery).""" @@ -1054,3 +1159,7 @@ def get_vlans(self) -> dict: def get_modules(self) -> dict | None: """Return per-chassis module / module bay inventory or None.""" return _nokia_sros_get_modules_impl(self) + + def get_network_instances(self, name: str = "") -> dict: + """Return network instances (VPRNs as VRFs) keyed by name, NAPALM OC shape.""" + return _nokia_sros_get_network_instances_impl(self, name) diff --git a/device-discovery/tests/custom_drivers/base_test.py b/device-discovery/tests/custom_drivers/base_test.py index 8cad71e0..b7590f5a 100644 --- a/device-discovery/tests/custom_drivers/base_test.py +++ b/device-discovery/tests/custom_drivers/base_test.py @@ -261,6 +261,40 @@ def test_get_chassis_members(self, scenario: str) -> None: expected = json.loads(expected_path.read_text(encoding="utf-8")) assert result == expected + def test_get_network_instances(self, scenario: str) -> None: + """Verify get_network_instances payload shape (driver-optional).""" + from napalm.base.base import NetworkDriver + + if self.driver_cls.get_network_instances is NetworkDriver.get_network_instances: + pytest.skip( + f"{self.driver_cls.__name__} does not implement get_network_instances" + ) + mock_dir = self._mock_dir("test_get_network_instances", scenario) + if not mock_dir.is_dir(): + # Covers drivers that inherit the getter from upstream NAPALM + # (ios/eos/junos/nxos) — those are exercised by upstream, not here. + pytest.skip("no get_network_instances fixtures for this driver") + driver = self._build_driver(mock_dir) + result = driver.get_network_instances() + + assert isinstance(result, dict), "get_network_instances must return a dict" + for ni_name, ni in result.items(): + assert isinstance(ni, dict), f"{ni_name}: instance must be a dict" + assert ni.get("name") == ni_name, f"{ni_name}: name/key mismatch" + assert isinstance(ni.get("type"), str), f"{ni_name}: missing type" + state = ni.get("state") + assert isinstance(state, dict) and "route_distinguisher" in state, ( + f"{ni_name}: missing state.route_distinguisher" + ) + interfaces = ni.get("interfaces") + assert isinstance(interfaces, dict) and isinstance( + interfaces.get("interface"), dict + ), f"{ni_name}: malformed interfaces envelope" + + expected = _load_expected(mock_dir) + if (mock_dir / "expected_result.json").exists(): + assert result == expected + def test_get_modules(self, scenario: str) -> None: """Verify get_modules payload shape (driver-optional).""" mock_dir = self._mock_dir("test_get_modules", scenario) diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/missing_af_section/expected_result.json b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/missing_af_section/expected_result.json new file mode 100644 index 00000000..25c65644 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/missing_af_section/expected_result.json @@ -0,0 +1,36 @@ +{ + "RED": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/1": {} + } + }, + "name": "RED", + "state": { + "route_distinguisher": "65000:100" + }, + "type": "L3VRF" + }, + "STAGING": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/5": {} + } + }, + "name": "STAGING", + "state": { + "route_distinguisher": "" + }, + "type": "L3VRF" + }, + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/missing_af_section/show_vrf_all_detail.txt b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/missing_af_section/show_vrf_all_detail.txt new file mode 100644 index 00000000..05bc4ac9 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/missing_af_section/show_vrf_all_detail.txt @@ -0,0 +1,14 @@ +VRF STAGING; RD not set; VPN ID not set +VRF mode: Regular +Description not set +Interfaces: + GigabitEthernet0/0/0/5 + +VRF RED; RD 65000:100; VPN ID not set +VRF mode: Regular +Description Customer RED L3VPN +Interfaces: + GigabitEthernet0/0/0/1 +Address family IPV4 Unicast + No import route policy + No export route policy diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/no_vrfs/expected_result.json b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/no_vrfs/expected_result.json new file mode 100644 index 00000000..b67d8c24 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/no_vrfs/expected_result.json @@ -0,0 +1,12 @@ +{ + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/normal/expected_result.json b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/normal/expected_result.json new file mode 100644 index 00000000..4290e968 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/normal/expected_result.json @@ -0,0 +1,37 @@ +{ + "MGMT": { + "interfaces": { + "interface": { + "MgmtEth0/RP0/CPU0/0": {} + } + }, + "name": "MGMT", + "state": { + "route_distinguisher": "" + }, + "type": "L3VRF" + }, + "RED": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/1": {}, + "GigabitEthernet0/0/0/2.100": {} + } + }, + "name": "RED", + "state": { + "route_distinguisher": "65000:100" + }, + "type": "L3VRF" + }, + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/normal/show_vrf_all_detail.txt b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/normal/show_vrf_all_detail.txt new file mode 100644 index 00000000..71dc3a5b --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/normal/show_vrf_all_detail.txt @@ -0,0 +1,22 @@ +VRF RED; RD 65000:100; VPN ID not set +VRF mode: Regular +Description Customer RED L3VPN +Interfaces: + GigabitEthernet0/0/0/1 + GigabitEthernet0/0/0/2.100 +Address family IPV4 Unicast + Import VPN route-target communities: + RT:65000:100 + Export VPN route-target communities: + RT:65000:100 + No import route policy + No export route policy + +VRF MGMT; RD not set; VPN ID not set +VRF mode: Regular +Description not set +Interfaces: + MgmtEth0/RP0/CPU0/0 +Address family IPV4 Unicast + No import route policy + No export route policy diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/route_policy_attached/expected_result.json b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/route_policy_attached/expected_result.json new file mode 100644 index 00000000..092a294f --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/route_policy_attached/expected_result.json @@ -0,0 +1,37 @@ +{ + "BLUE": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/3": {} + } + }, + "name": "BLUE", + "state": { + "route_distinguisher": "65000:10" + }, + "type": "L3VRF" + }, + "GREEN": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/4": {}, + "GigabitEthernet0/0/0/5.200": {} + } + }, + "name": "GREEN", + "state": { + "route_distinguisher": "65000:20" + }, + "type": "L3VRF" + }, + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/route_policy_attached/show_vrf_all_detail.txt b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/route_policy_attached/show_vrf_all_detail.txt new file mode 100644 index 00000000..acec8799 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr/mock_data/test_get_network_instances/route_policy_attached/show_vrf_all_detail.txt @@ -0,0 +1,22 @@ +VRF BLUE; RD 65000:10; VPN ID not set +VRF mode: Regular +Description not set +Interfaces: + GigabitEthernet0/0/0/3 +Address family IPV4 Unicast + Import VPN route-target communities: + RT:65000:10 + Export VPN route-target communities: + RT:65000:10 + Import route policy: RPL_BLUE_IN + Export route policy: RPL_BLUE_OUT + +VRF GREEN; RD 65000:20; VPN ID not set +VRF mode: Regular +Description not set +Interfaces: + GigabitEthernet0/0/0/4 + GigabitEthernet0/0/0/5.200 +Address family IPV4 Unicast + No import route policy + No export route policy diff --git a/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/default_row/expected_result.json b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/default_row/expected_result.json new file mode 100644 index 00000000..3e14ead0 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/default_row/expected_result.json @@ -0,0 +1,24 @@ +{ + "RED": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/1": {} + } + }, + "name": "RED", + "state": { + "route_distinguisher": "65000:100" + }, + "type": "L3VRF" + }, + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/default_row/response.xml b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/default_row/response.xml new file mode 100644 index 00000000..ca9592c1 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/default_row/response.xml @@ -0,0 +1,22 @@ + + + + + + default + not set + + GigabitEthernet0/0/0/9 + + + + RED + 65000:100 + + GigabitEthernet0/0/0/1 + + + + + + diff --git a/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/no_vrfs/expected_result.json b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/no_vrfs/expected_result.json new file mode 100644 index 00000000..b67d8c24 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/no_vrfs/expected_result.json @@ -0,0 +1,12 @@ +{ + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/no_vrfs/response.xml b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/no_vrfs/response.xml new file mode 100644 index 00000000..30bfa593 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/no_vrfs/response.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/normal/expected_result.json b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/normal/expected_result.json new file mode 100644 index 00000000..4290e968 --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/normal/expected_result.json @@ -0,0 +1,37 @@ +{ + "MGMT": { + "interfaces": { + "interface": { + "MgmtEth0/RP0/CPU0/0": {} + } + }, + "name": "MGMT", + "state": { + "route_distinguisher": "" + }, + "type": "L3VRF" + }, + "RED": { + "interfaces": { + "interface": { + "GigabitEthernet0/0/0/1": {}, + "GigabitEthernet0/0/0/2.100": {} + } + }, + "name": "RED", + "state": { + "route_distinguisher": "65000:100" + }, + "type": "L3VRF" + }, + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/normal/response.xml b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/normal/response.xml new file mode 100644 index 00000000..a3fa897a --- /dev/null +++ b/device-discovery/tests/custom_drivers/iosxr_netconf/mock_data/test_get_network_instances/normal/response.xml @@ -0,0 +1,25 @@ + + + + + + RED + 65000:100 + + GigabitEthernet0/0/0/1 + + + GigabitEthernet0/0/0/2.100 + + + + MGMT + not set + + MgmtEth0/RP0/CPU0/0 + + + + + + diff --git a/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/empty/expected_result.json b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/empty/expected_result.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/empty/expected_result.json @@ -0,0 +1 @@ +{} diff --git a/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/expected_result.json b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/expected_result.json new file mode 100644 index 00000000..52cf893d --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/expected_result.json @@ -0,0 +1,48 @@ +{ + "default": { + "interfaces": { + "interface": {} + }, + "name": "default", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + }, + "macvrf-10": { + "interfaces": { + "interface": { + "ethernet-1/4.10": {} + } + }, + "name": "macvrf-10", + "state": { + "route_distinguisher": "" + }, + "type": "L2VSI" + }, + "mgmt": { + "interfaces": { + "interface": { + "mgmt0.0": {} + } + }, + "name": "mgmt", + "state": { + "route_distinguisher": "" + }, + "type": "L3VRF" + }, + "vrf-red": { + "interfaces": { + "interface": { + "ethernet-1/3.100": {} + } + }, + "name": "vrf-red", + "state": { + "route_distinguisher": "65000:300" + }, + "type": "L3VRF" + } +} diff --git a/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_interface_name.txt b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_interface_name.txt new file mode 100644 index 00000000..a41d3f67 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_interface_name.txt @@ -0,0 +1,23 @@ + network-instance default { + interface ethernet-1/1.0 { + name ethernet-1/1.0 + } + interface ethernet-1/2.0 { + name ethernet-1/2.0 + } + } + network-instance mgmt { + interface mgmt0.0 { + name mgmt0.0 + } + } + network-instance vrf-red { + interface ethernet-1/3.100 { + name ethernet-1/3.100 + } + } + network-instance macvrf-10 { + interface ethernet-1/4.10 { + name ethernet-1/4.10 + } + } diff --git a/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_protocols_bgp-vpn_bgp-instance_route-distinguisher_rd.txt b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_protocols_bgp-vpn_bgp-instance_route-distinguisher_rd.txt new file mode 100644 index 00000000..012bbddf --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_protocols_bgp-vpn_bgp-instance_route-distinguisher_rd.txt @@ -0,0 +1,11 @@ + network-instance vrf-red { + protocols { + bgp-vpn { + bgp-instance 1 { + route-distinguisher { + rd 65000:300 + } + } + } + } + } diff --git a/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_type.txt b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_type.txt new file mode 100644 index 00000000..1799835f --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_srl/mock_data/test_get_network_instances/normal/info_from_state_network-instance_type.txt @@ -0,0 +1,12 @@ + network-instance default { + type default + } + network-instance mgmt { + type ip-vrf + } + network-instance vrf-red { + type ip-vrf + } + network-instance macvrf-10 { + type mac-vrf + } diff --git a/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/base_named_vprn/expected_result.json b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/base_named_vprn/expected_result.json new file mode 100644 index 00000000..bc8529eb --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/base_named_vprn/expected_result.json @@ -0,0 +1,24 @@ +{ + "Base": { + "interfaces": { + "interface": {} + }, + "name": "Base", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + }, + "CUST-RED": { + "interfaces": { + "interface": { + "CUST-RED/to-red-ce1": {} + } + }, + "name": "CUST-RED", + "state": { + "route_distinguisher": "65000:300" + }, + "type": "L3VRF" + } +} diff --git a/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/base_named_vprn/response.xml b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/base_named_vprn/response.xml new file mode 100644 index 00000000..4c09bcd3 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/base_named_vprn/response.xml @@ -0,0 +1,25 @@ + + + + + + Base + + rogue-if + + + + CUST-RED + + + 65000:300 + + + + to-red-ce1 + + + + + + diff --git a/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/no_vprns/expected_result.json b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/no_vprns/expected_result.json new file mode 100644 index 00000000..50c558d9 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/no_vprns/expected_result.json @@ -0,0 +1,12 @@ +{ + "Base": { + "interfaces": { + "interface": {} + }, + "name": "Base", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + } +} diff --git a/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/no_vprns/response.xml b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/no_vprns/response.xml new file mode 100644 index 00000000..da5f9178 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/no_vprns/response.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/normal/expected_result.json b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/normal/expected_result.json new file mode 100644 index 00000000..a7f96f52 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/normal/expected_result.json @@ -0,0 +1,37 @@ +{ + "Base": { + "interfaces": { + "interface": {} + }, + "name": "Base", + "state": { + "route_distinguisher": "" + }, + "type": "DEFAULT_INSTANCE" + }, + "CUST-BLUE": { + "interfaces": { + "interface": { + "CUST-BLUE/to-blue-ce1": {}, + "CUST-BLUE/to-blue-ce2": {} + } + }, + "name": "CUST-BLUE", + "state": { + "route_distinguisher": "65000:200" + }, + "type": "L3VRF" + }, + "MGMT-VPRN": { + "interfaces": { + "interface": { + "MGMT-VPRN/mgmt-if": {} + } + }, + "name": "MGMT-VPRN", + "state": { + "route_distinguisher": "" + }, + "type": "L3VRF" + } +} diff --git a/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/normal/response.xml b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/normal/response.xml new file mode 100644 index 00000000..30776020 --- /dev/null +++ b/device-discovery/tests/custom_drivers/nokia_sros/mock_data/test_get_network_instances/normal/response.xml @@ -0,0 +1,28 @@ + + + + + + CUST-BLUE + + + 65000:200 + + + + to-blue-ce1 + + + to-blue-ce2 + + + + MGMT-VPRN + + mgmt-if + + + + + + diff --git a/device-discovery/tests/test_runner_vrf_dispatch.py b/device-discovery/tests/test_runner_vrf_dispatch.py index 75de9939..e8e16737 100644 --- a/device-discovery/tests/test_runner_vrf_dispatch.py +++ b/device-discovery/tests/test_runner_vrf_dispatch.py @@ -19,7 +19,11 @@ from custom_napalm.eos import EOSDriver from custom_napalm.ios import IOSDriver +from custom_napalm.iosxr import IOSXRDriver +from custom_napalm.iosxr_netconf import IOSXRNETCONFDriver from custom_napalm.junos import JunOSDriver +from custom_napalm.nokia_srl import SRLDriver +from custom_napalm.nokia_sros import SROSDriver from custom_napalm.nxos import NXOSDriver from custom_napalm.nxos_ssh import NXOSSSHDriver from device_discovery.policy.models import Config, Defaults, Options @@ -112,19 +116,40 @@ def test_collect_network_instances_swallows_not_implemented(caplog) -> None: assert any("Error getting network instances" in r.message for r in caplog.records) -# Pins the inheritance assumption VRF discovery relies on: these drivers get -# a real get_network_instances() from upstream NAPALM (not the base-class -# stub that raises NotImplementedError). Catches upstream drift. +# Pins the support matrix VRF discovery relies on: these drivers carry a real +# get_network_instances() and it lives in the expected package — inherited +# from upstream NAPALM (ios/eos/junos/nxos/nxos_ssh) or implemented in +# custom_napalm (iosxr/iosxr_netconf/nokia_sros/nokia_srl). Asserting the +# owning module (not just "is not the base-class stub") also catches the +# case where a wrapper driver accidentally shadows the upstream getter, +# which a bare identity check against NetworkDriver would miss. @pytest.mark.parametrize( - "driver_cls", + ("driver_cls", "expected_module_prefix"), [ - pytest.param(IOSDriver, id="ios"), - pytest.param(EOSDriver, id="eos"), - pytest.param(JunOSDriver, id="junos"), - pytest.param(NXOSDriver, id="nxos"), - pytest.param(NXOSSSHDriver, id="nxos_ssh"), + pytest.param(IOSDriver, "napalm.", id="ios"), + pytest.param(EOSDriver, "napalm.", id="eos"), + pytest.param(JunOSDriver, "napalm.", id="junos"), + pytest.param(NXOSDriver, "napalm.", id="nxos"), + pytest.param(NXOSSSHDriver, "napalm.", id="nxos_ssh"), + pytest.param(IOSXRDriver, "custom_napalm.", id="iosxr"), + pytest.param(IOSXRNETCONFDriver, "custom_napalm.", id="iosxr_netconf"), + pytest.param(SROSDriver, "custom_napalm.", id="nokia_sros"), + pytest.param(SRLDriver, "custom_napalm.", id="nokia_srl"), ], ) -def test_driver_implements_get_network_instances(driver_cls) -> None: - """Driver overrides the NAPALM base-class get_network_instances stub.""" - assert driver_cls.get_network_instances is not NetworkDriver.get_network_instances +def test_driver_implements_get_network_instances( + driver_cls, expected_module_prefix +) -> None: + """get_network_instances is owned by the expected package, not a stub.""" + owner = next( + klass + for klass in driver_cls.__mro__ + if "get_network_instances" in klass.__dict__ + ) + assert ( + owner is not NetworkDriver + ), f"{driver_cls.__name__} resolves to the NAPALM base-class stub" + assert owner.__module__.startswith(expected_module_prefix), ( + f"{driver_cls.__name__}.get_network_instances is owned by " + f"{owner.__module__}, expected {expected_module_prefix}*" + )