diff --git a/changelog/undistributed/changelog_show_ip_ospf_neighbors_20260910.rst b/changelog/undistributed/changelog_show_ip_ospf_neighbors_20260910.rst new file mode 100644 index 000000000..b3611d191 --- /dev/null +++ b/changelog/undistributed/changelog_show_ip_ospf_neighbors_20260910.rst @@ -0,0 +1,13 @@ +-------------------------------------------------------------------------------- + New +-------------------------------------------------------------------------------- +* NXOS + * Added ShowIpOspfNeighbors: + * Added parser and schema for show ip ospf neighbors. + * Extracts process, VRF, neighbor count, interface, router ID, priority, + adjacency state, neighbor role, uptime, and neighbor address. + * Preserves identical router IDs on different interfaces and reports + explicit zero-neighbor summaries as structured data. + * Rejects incomplete tables whose parsed row count differs from the + reported count. + * Adds lab-derived and synthetic fixtures and malformed-input tests. diff --git a/sdk_generator/outputs/github_parser.json b/sdk_generator/outputs/github_parser.json index 8b7eba2ec..721ea62e9 100644 --- a/sdk_generator/outputs/github_parser.json +++ b/sdk_generator/outputs/github_parser.json @@ -43362,6 +43362,22 @@ } } }, + "show ip ospf neighbors": { + "folders": { + "nxos": { + "class": "ShowIpOspfNeighbors", + "doc": "Parser for 'show ip ospf neighbors'.", + "module_name": "nxos.show_ospf", + "package": "genie.libs.parser", + "schema": "{\n 'vrf': {\n Any('*'): {\n 'address_family': {\n 'ipv4': {\n 'instance': {\n Any('*'): {\n 'total_neighbors': int,\n Optional('interfaces'): {\n Any('*'): {\n 'neighbors': {\n Any('*'): {\n 'neighbor_router_id': str,\n 'priority': int,\n 'state': str,\n 'neighbor_role': str,\n 'up_time': str,\n 'address': str,\n },\n },\n },\n },\n },\n },\n },\n },\n },\n },\n}", + "tokens": { + "os": "nxos" + }, + "uid": "show_ip_ospf_neighbors", + "url": "https://github.com/CiscoTestAutomation/genieparser/tree/master/src/genie/libs/parser/nxos/show_ospf.py#L2393" + } + } + }, "show ip ospf neighbors detail": { "folders": { "nxos": { diff --git a/src/genie/libs/parser/nxos/show_ospf.py b/src/genie/libs/parser/nxos/show_ospf.py index 5f64508d5..1b9f73f91 100755 --- a/src/genie/libs/parser/nxos/show_ospf.py +++ b/src/genie/libs/parser/nxos/show_ospf.py @@ -12,6 +12,7 @@ * show ip ospf interface * show ip ospf interface vrf * show ip ospf neighbors detail + * show ip ospf neighbors * show ip ospf neighbors detail vrf * show ip ospf database external detail * show ip ospf database external detail vrf @@ -2352,6 +2353,123 @@ def cli(self, vrf='', interface='', output=None): return ret_dict +# ====================================================== +# Schema for 'show ip ospf neighbors' +# ====================================================== +class ShowIpOspfNeighborsSchema(MetaParser): + """Schema for 'show ip ospf neighbors'.""" + + schema = { + 'vrf': { + Any(): { + 'address_family': { + 'ipv4': { + 'instance': { + Any(): { + 'total_neighbors': int, + Optional('interfaces'): { + Any(): { + 'neighbors': { + Any(): { + 'neighbor_router_id': str, + 'priority': int, + 'state': str, + 'neighbor_role': str, + 'up_time': str, + 'address': str, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + +class ShowIpOspfNeighbors(ShowIpOspfNeighborsSchema): + """Parser for 'show ip ospf neighbors'.""" + + cli_command = 'show ip ospf neighbors' + + def cli(self, output=None): + if output is None: + output = self.device.execute(self.cli_command) + + result = {} + instance = None + # OSPF Process ID UNDERLAY VRF default + p_header = re.compile( + r'^OSPF Process ID (?P\S+) VRF (?P\S+)$') + # Total number of neighbors: 2 + p_total = re.compile(r'^Total number of neighbors:\s*(?P\d+)$') + # 10.2.0.3 1 FULL/ - 02:00:11 10.4.0.2 Eth1/1 + p_neighbor = re.compile( + r'^(?P\d+\.\d+\.\d+\.\d+)\s+' + r'(?P\d+)\s+(?P\S+?)\s*/\s*' + r'(?P\S+)\s+(?P\S+)\s+' + r'(?P
\d+\.\d+\.\d+\.\d+)\s+' + r'(?P\S+)$') + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + match = p_header.match(line) + if match: + group = match.groupdict() + instance = ( + result.setdefault('vrf', {}) + .setdefault(group['vrf'], {}) + .setdefault('address_family', {}) + .setdefault('ipv4', {}) + .setdefault('instance', {}) + .setdefault(group['process'], {}) + ) + continue + match = p_total.match(line) + if match: + if instance is None: + raise ValueError( + 'OSPF neighbor count without process header') + instance['total_neighbors'] = int(match.group('total')) + continue + match = p_neighbor.match(line) + if match: + if instance is None: + raise ValueError('OSPF neighbor row without process header') + group = match.groupdict() + interface = Common.convert_intf_name(group.pop('interface')) + group['priority'] = int(group['priority']) + group['state'] = group['state'].lower() + group['neighbor_role'] = group['neighbor_role'].lower() + neighbors = instance.setdefault('interfaces', {}).setdefault( + interface, {}).setdefault('neighbors', {}) + neighbor_id = group['neighbor_router_id'] + if neighbor_id in neighbors: + raise ValueError( + 'Duplicate OSPF neighbor on the same interface') + neighbors[neighbor_id] = group + + # A positive count must not silently become an empty/partial table. + for vrf_data in result.get('vrf', {}).values(): + instances = vrf_data['address_family']['ipv4']['instance'] + for instance_data in instances.values(): + count = sum(len(data['neighbors']) for data in + instance_data.get('interfaces', {}).values()) + if 'total_neighbors' not in instance_data: + raise ValueError( + 'OSPF summary is missing the neighbor count') + if count != instance_data['total_neighbors']: + raise ValueError( + 'OSPF neighbor count does not match parsed rows') + + return result + + # ======================================================= # Schema for 'show ip ospf neighbors detail [vrf ]' # ======================================================= diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/empty/golden_output1_output.txt b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/empty/golden_output1_output.txt new file mode 100644 index 000000000..e69de29bb diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output1_expected.py b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output1_expected.py new file mode 100644 index 000000000..9c0289a21 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output1_expected.py @@ -0,0 +1,29 @@ +expected_output = { + "vrf": { + "default": { + "address_family": { + "ipv4": { + "instance": { + "UNDERLAY": { + "total_neighbors": 1, + "interfaces": { + "Ethernet1/2": { + "neighbors": { + "10.2.0.3": { + "neighbor_router_id": "10.2.0.3", + "priority": 1, + "state": "full", + "neighbor_role": "-", + "up_time": "01:51:39", + "address": "10.4.0.5" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output1_output.txt b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output1_output.txt new file mode 100644 index 000000000..64b342c70 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output1_output.txt @@ -0,0 +1,4 @@ +OSPF Process ID UNDERLAY VRF default + Total number of neighbors: 1 + Neighbor ID Pri State Up Time Address Interface + 10.2.0.3 1 FULL/ - 01:51:39 10.4.0.5 Eth1/2 diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output2_expected.py b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output2_expected.py new file mode 100644 index 000000000..fc00335cb --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output2_expected.py @@ -0,0 +1,41 @@ +expected_output = { + "vrf": { + "default": { + "address_family": { + "ipv4": { + "instance": { + "UNDERLAY": { + "total_neighbors": 2, + "interfaces": { + "Ethernet1/1": { + "neighbors": { + "10.2.0.2": { + "neighbor_router_id": "10.2.0.2", + "priority": 1, + "state": "full", + "neighbor_role": "-", + "up_time": "02:00:19", + "address": "10.4.0.1" + } + } + }, + "Ethernet1/2": { + "neighbors": { + "10.2.0.1": { + "neighbor_router_id": "10.2.0.1", + "priority": 1, + "state": "full", + "neighbor_role": "-", + "up_time": "01:51:38", + "address": "10.4.0.6" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output2_output.txt b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output2_output.txt new file mode 100644 index 000000000..52d164052 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output2_output.txt @@ -0,0 +1,5 @@ +OSPF Process ID UNDERLAY VRF default + Total number of neighbors: 2 + Neighbor ID Pri State Up Time Address Interface + 10.2.0.2 1 FULL/ - 02:00:19 10.4.0.1 Eth1/1 + 10.2.0.1 1 FULL/ - 01:51:38 10.4.0.6 Eth1/2 diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output3_expected.py b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output3_expected.py new file mode 100644 index 000000000..27f7a7e66 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output3_expected.py @@ -0,0 +1,53 @@ +expected_output = { + "vrf": { + "default": { + "address_family": { + "ipv4": { + "instance": { + "UNDERLAY": { + "total_neighbors": 3, + "interfaces": { + "Ethernet1/1": { + "neighbors": { + "20.2.0.1": { + "neighbor_router_id": "20.2.0.1", + "priority": 1, + "state": "full", + "neighbor_role": "-", + "up_time": "01:51:30", + "address": "20.4.0.2" + } + } + }, + "Ethernet1/2": { + "neighbors": { + "20.2.0.2": { + "neighbor_router_id": "20.2.0.2", + "priority": 1, + "state": "full", + "neighbor_role": "-", + "up_time": "01:59:59", + "address": "20.4.0.6" + } + } + }, + "Ethernet1/3": { + "neighbors": { + "20.2.0.3": { + "neighbor_router_id": "20.2.0.3", + "priority": 1, + "state": "full", + "neighbor_role": "-", + "up_time": "01:59:57", + "address": "20.4.0.10" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output3_output.txt b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output3_output.txt new file mode 100644 index 000000000..26093e3fe --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output3_output.txt @@ -0,0 +1,6 @@ +OSPF Process ID UNDERLAY VRF default + Total number of neighbors: 3 + Neighbor ID Pri State Up Time Address Interface + 20.2.0.1 1 FULL/ - 01:51:30 20.4.0.2 Eth1/1 + 20.2.0.2 1 FULL/ - 01:59:59 20.4.0.6 Eth1/2 + 20.2.0.3 1 FULL/ - 01:59:57 20.4.0.10 Eth1/3 diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output4_expected.py b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output4_expected.py new file mode 100644 index 000000000..2d70928c0 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output4_expected.py @@ -0,0 +1,41 @@ +expected_output = { + "vrf": { + "default": { + "address_family": { + "ipv4": { + "instance": { + "10": { + "total_neighbors": 2, + "interfaces": { + "Ethernet1/1": { + "neighbors": { + "192.0.2.1": { + "neighbor_router_id": "192.0.2.1", + "priority": 1, + "state": "full", + "neighbor_role": "dr", + "up_time": "1d02h", + "address": "192.0.2.1" + } + } + }, + "Ethernet1/2": { + "neighbors": { + "192.0.2.1": { + "neighbor_router_id": "192.0.2.1", + "priority": 0, + "state": "2way", + "neighbor_role": "drother", + "up_time": "00:00:03", + "address": "192.0.2.5" + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output4_output.txt b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output4_output.txt new file mode 100644 index 000000000..870474381 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output4_output.txt @@ -0,0 +1,5 @@ +OSPF Process ID 10 VRF default + Total number of neighbors: 2 + Neighbor ID Pri State Up Time Address Interface + 192.0.2.1 1 FULL/ DR 1d02h 192.0.2.1 Eth1/1 + 192.0.2.1 0 2WAY/ DROTHER 00:00:03 192.0.2.5 Eth1/2 diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output5_expected.py b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output5_expected.py new file mode 100644 index 000000000..5d656adc9 --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output5_expected.py @@ -0,0 +1,15 @@ +expected_output = { + "vrf": { + "default": { + "address_family": { + "ipv4": { + "instance": { + "UNDERLAY": { + "total_neighbors": 0 + } + } + } + } + } + } +} diff --git a/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output5_output.txt b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output5_output.txt new file mode 100644 index 000000000..b8f006f0c --- /dev/null +++ b/src/genie/libs/parser/nxos/tests/ShowIpOspfNeighbors/cli/equal/golden_output5_output.txt @@ -0,0 +1,3 @@ +OSPF Process ID UNDERLAY VRF default + Total number of neighbors: 0 + Neighbor ID Pri State Up Time Address Interface diff --git a/tests/test_nxos_ospf_neighbors_summary.py b/tests/test_nxos_ospf_neighbors_summary.py new file mode 100644 index 000000000..b84219bf0 --- /dev/null +++ b/tests/test_nxos_ospf_neighbors_summary.py @@ -0,0 +1,89 @@ +"""Focused regression tests for NX-OS OSPF neighbor summaries.""" +import json +from pathlib import Path +import runpy +import unittest +from unittest.mock import Mock + +from genie.conf.base import Device +from genie.libs.parser.nxos.show_ospf import ShowIpOspfNeighbors +from genie.metaparser.util.exceptions import SchemaEmptyParserError + + +FIXTURES = (Path(__file__).resolve().parents[1] / 'src/genie/libs/parser/nxos' + / 'tests/ShowIpOspfNeighbors/cli/equal') + + +class TestOspfNeighborsSummary(unittest.TestCase): + def test_expected_fixtures(self): + for path in sorted(FIXTURES.glob('*_output.txt')): + with self.subTest(fixture=path.name): + expected_file = path.with_name( + path.name.replace('_output.txt', '_expected.py')) + expected = runpy.run_path( + str(expected_file))['expected_output'] + actual = ShowIpOspfNeighbors(device=None).parse( + output=path.read_text()) + self.assertEqual(actual, expected) + json.dumps(actual) + + def test_runtime_command_lookup(self): + raw = (FIXTURES / 'golden_output1_output.txt').read_text() + device = Device(name='offline-nxos', os='nxos') + self.assertEqual(device.parse('show ip ospf neighbors', output=raw), + ShowIpOspfNeighbors(device=None).parse(output=raw)) + + def test_execute_when_output_is_not_supplied(self): + raw = (FIXTURES / 'golden_output1_output.txt').read_text() + device = Mock() + device.execute.return_value = raw + ShowIpOspfNeighbors(device=device).parse() + device.execute.assert_called_once_with('show ip ospf neighbors') + + def test_empty_output(self): + with self.assertRaises(SchemaEmptyParserError): + ShowIpOspfNeighbors(device=None).parse(output='') + + def test_multiple_processes_and_vrfs(self): + raw = (FIXTURES / 'golden_output5_output.txt').read_text() + combined = (raw + raw.replace('UNDERLAY', '10') + + raw.replace('default', 'tenant-a')) + parsed = ShowIpOspfNeighbors(device=None).parse(output=combined) + self.assertEqual(set(parsed['vrf']), {'default', 'tenant-a'}) + instances = ( + parsed['vrf']['default']['address_family']['ipv4']['instance']) + self.assertEqual(set(instances), {'UNDERLAY', '10'}) + self.assertEqual(instances['10'], {'total_neighbors': 0}) + + def test_reject_truncated_table(self): + raw = (FIXTURES / 'golden_output3_output.txt').read_text() + with self.assertRaises(ValueError): + output = '\n'.join(raw.splitlines()[:-1]) + ShowIpOspfNeighbors(device=None).parse(output=output) + + def test_reject_unparsed_neighbor_row(self): + raw = (FIXTURES / 'golden_output1_output.txt').read_text() + with self.assertRaises(ValueError): + output = raw.replace('FULL/ -', '???') + ShowIpOspfNeighbors(device=None).parse(output=output) + + def test_reject_duplicate_neighbor(self): + raw = (FIXTURES / 'golden_output1_output.txt').read_text() + duplicate = raw.replace('neighbors: 1', 'neighbors: 2') + duplicate += raw.splitlines()[-1] + '\n' + with self.assertRaises(ValueError): + ShowIpOspfNeighbors(device=None).parse(output=duplicate) + + def test_reject_missing_header_or_count(self): + raw = (FIXTURES / 'golden_output1_output.txt').read_text() + for missing_line in (0, 1): + with self.subTest(missing_line=missing_line): + lines = raw.splitlines() + del lines[missing_line] + with self.assertRaises(ValueError): + output = '\n'.join(lines) + ShowIpOspfNeighbors(device=None).parse(output=output) + + +if __name__ == '__main__': + unittest.main()