From 51c7f9eef6ea8d70178716edb34fea5bafac572f Mon Sep 17 00:00:00 2001 From: Chris Wiggins Date: Sun, 24 Nov 2024 21:40:02 +0800 Subject: [PATCH] feat: Add FRR structured output for BGP Routes --- .samples/frr_bgp_route.json | 13 +-- hyperglass/constants.py | 2 +- hyperglass/defaults/directives/frr.py | 23 +++++ hyperglass/models/parsing/frr.py | 23 ++--- hyperglass/plugins/_builtin/__init__.py | 2 + hyperglass/plugins/_builtin/bgp_route_frr.py | 86 +++++++++++++++++++ .../plugins/tests/test_bgp_route_frr.py | 55 ++++++++++++ 7 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 hyperglass/plugins/_builtin/bgp_route_frr.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_frr.py diff --git a/.samples/frr_bgp_route.json b/.samples/frr_bgp_route.json index 90b936d..aae884c 100644 --- a/.samples/frr_bgp_route.json +++ b/.samples/frr_bgp_route.json @@ -12,13 +12,8 @@ ], "length": 2 }, - "aggregatorAs": 13335, - "aggregatorId": "108.162.239.1", "origin": "IGP", - "med": 25090, - "metric": 25090, - "localpref": 100, - "weight": 100, + "locPrf": 100, "valid": true, "community": { "string": "174:21001 174:22003 14525:0 14525:40 14525:1021 14525:2840 14525:3003 14525:4004 14525:9001", @@ -70,7 +65,7 @@ "origin": "IGP", "med": 0, "metric": 0, - "localpref": 150, + "locPrf": 150, "weight": 200, "valid": true, "bestpath": { @@ -124,7 +119,7 @@ "origin": "IGP", "med": 0, "metric": 0, - "localpref": 100, + "locPrf": 100, "weight": 100, "valid": true, "bestpath": { @@ -180,7 +175,7 @@ "origin": "IGP", "med": 2020, "metric": 2020, - "localpref": 150, + "locPrf": 150, "weight": 200, "valid": true, "bestpath": { diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 5fb0870..0139c74 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,7 @@ TARGET_FORMAT_SPACE = ("huawei", "huawei_vrpv8") TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/frr.py b/hyperglass/defaults/directives/frr.py index 09e8f2d..eb6baaf 100644 --- a/hyperglass/defaults/directives/frr.py +++ b/hyperglass/defaults/directives/frr.py @@ -15,6 +15,7 @@ __all__ = ( "FRRouting_BGPRoute", "FRRouting_Ping", "FRRouting_Traceroute", + "FRRouting_BGPRouteTable", ) NAME = "FRRouting" @@ -36,6 +37,7 @@ FRRouting_BGPRoute = BuiltinDirective( ), ], field=Text(description="IP Address, Prefix, or Hostname"), + table_output="__hyperglass_frr_bgp_route_table__", platforms=PLATFORMS, ) @@ -110,3 +112,24 @@ FRRouting_Traceroute = BuiltinDirective( field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +FRRouting_BGPRouteTable = BuiltinDirective( + id="__hyperglass_frr_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command='vtysh -c "show bgp ipv4 unicast {target} json"', + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command='vtysh -c "show bgp ipv6 unicast {target} json"', + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/models/parsing/frr.py b/hyperglass/models/parsing/frr.py index 895046b..2dbac8a 100644 --- a/hyperglass/models/parsing/frr.py +++ b/hyperglass/models/parsing/frr.py @@ -14,7 +14,7 @@ from hyperglass.models.data import BGPRouteTable # Local from ..main import HyperglassModel -FRRPeerType = t.Literal["internal", "external"] +FRRPeerType = t.Literal["internal", "external", "confed-internal", "confed-external"] def _alias_generator(field): @@ -48,11 +48,12 @@ class FRRPath(_FRRBase): """FRR Path Model.""" aspath: t.List[int] - aggregator_as: int - aggregator_id: str + aggregator_as: int = 0 + aggregator_id: str = "" + loc_prf: int = 100 # 100 is the default value for local preference + metric: int = 0 med: int = 0 - localpref: int - weight: int + weight: int = 0 valid: bool last_update: int bestpath: bool @@ -60,25 +61,26 @@ class FRRPath(_FRRBase): nexthops: t.List[FRRNextHop] peer: FRRPeer - @model_validator(pre=True) + @model_validator(mode="before") def validate_path(cls, values): """Extract meaningful data from FRR response.""" new = values.copy() new["aspath"] = values["aspath"]["segments"][0]["list"] - new["community"] = values["community"]["list"] + community = values.get("community", {'list': []}) + new["community"] = community["list"] new["lastUpdate"] = values["lastUpdate"]["epoch"] bestpath = values.get("bestpath", {}) new["bestpath"] = bestpath.get("overall", False) return new -class FRRRoute(_FRRBase): +class FRRBGPTable(_FRRBase): """FRR Route Model.""" prefix: str paths: t.List[FRRPath] = [] - def serialize(self): + def bgp_table(self): """Convert the FRR-specific fields to standard parsed data model.""" # TODO: somehow, get the actual VRF @@ -96,7 +98,7 @@ class FRRRoute(_FRRBase): "age": age, "weight": route.weight, "med": route.med, - "local_preference": route.localpref, + "local_preference": route.loc_prf, "as_path": route.aspath, "communities": route.community, "next_hop": route.nexthops[0].ip, @@ -104,6 +106,7 @@ class FRRRoute(_FRRBase): "source_rid": route.aggregator_id, "peer_rid": route.peer.peer_id, # TODO: somehow, get the actual RPKI state + # This depends on whether or not the RPKI module is enabled in FRR "rpki_state": 3, } ) diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 4a36861..cefb627 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -3,11 +3,13 @@ # Local from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista +from .bgp_route_frr import BGPRoutePluginFrr from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput __all__ = ( "BGPRoutePluginArista", + "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "MikrotikGarbageOutput", "RemoveCommand", diff --git a/hyperglass/plugins/_builtin/bgp_route_frr.py b/hyperglass/plugins/_builtin/bgp_route_frr.py new file mode 100644 index 0000000..627fad2 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_frr.py @@ -0,0 +1,86 @@ +"""Parse FRR JSON Response to Structured Data.""" + +# Standard Library +import json +import typing as t + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.frr import FRRBGPTable + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_frr(output: t.Sequence[str]) -> "OutputDataModel": + """Parse a FRR BGP JSON response.""" + result = None + + _log = log.bind(plugin=BGPRoutePluginFrr.__name__) + + for response in output: + try: + parsed: t.Dict = json.loads(response) + + _log.debug("Pre-parsed data", data=parsed) + + validated = FRRBGPTable(**parsed) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except json.JSONDecodeError as err: + _log.bind(error=str(err)).critical("Failed to decode JSON") + raise ParsingError("Error parsing response data") from err + + except KeyError as err: + _log.bind(key=str(err)).critical("Missing required key in response") + raise ParsingError("Error parsing response data") from err + + except IndexError as err: + _log.critical(err) + raise ParsingError("Error parsing response data") from err + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err.errors()) from err + + return result + +class BGPRoutePluginFrr(OutputPlugin): + """Coerce a FRR route table in JSON format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("frr",) + directives: t.Sequence[str] = ( + "__hyperglass_frr_bgp_route_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse FRR response if data is a string (and is therefore unparsed).""" + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_frr(output) + return output diff --git a/hyperglass/plugins/tests/test_bgp_route_frr.py b/hyperglass/plugins/tests/test_bgp_route_frr.py new file mode 100644 index 0000000..bd042b2 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_frr.py @@ -0,0 +1,55 @@ +"""FRR BGP Route Parsing Tests.""" + +# flake8: noqa +# Standard Library +from pathlib import Path + +# Third Party +import pytest + +# Project +from hyperglass.models.config.devices import Device +from hyperglass.models.data.bgp_route import BGPRouteTable + +# Local +from ._fixtures import MockDevice +from .._builtin.bgp_route_frr import BGPRoutePluginFrr + +DEPENDS_KWARGS = { + "depends": [ + "hyperglass/models/tests/test_util.py::test_check_legacy_fields", + "hyperglass/external/tests/test_rpki.py::test_rpki", + ], + "scope": "session", +} + +SAMPLE = Path(__file__).parent.parent.parent.parent / ".samples" / "frr_bgp_route.json" + + +def _tester(sample: str): + plugin = BGPRoutePluginFrr() + + device = MockDevice( + name="Test Device", + address="127.0.0.1", + group="Test Network", + credential={"username": "", "password": ""}, + platform="frr", + structured_output=True, + directives=["__hyperglass_frr_bgp_route_table__"], + attrs={"source4": "192.0.2.1", "source6": "2001:db8::1"}, + ) + + query = type("Query", (), {"device": device}) + + result = plugin.process(output=(sample,), query=query) + assert isinstance(result, BGPRouteTable), "Invalid parsed result" + assert hasattr(result, "count"), "BGP Table missing count" + assert result.count > 0, "BGP Table count is 0" + + +@pytest.mark.dependency(**DEPENDS_KWARGS) +def test_frr_route_sample(): + with SAMPLE.open("r") as file: + sample = file.read() + return _tester(sample)