1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 08:48:05 +00:00

feat: Add FRR structured output for BGP Routes

This commit is contained in:
Chris Wiggins 2024-11-24 21:40:02 +08:00 committed by Jason Hall
parent 7ae40e8cc8
commit 51c7f9eef6
7 changed files with 184 additions and 20 deletions

View file

@ -12,13 +12,8 @@
], ],
"length": 2 "length": 2
}, },
"aggregatorAs": 13335,
"aggregatorId": "108.162.239.1",
"origin": "IGP", "origin": "IGP",
"med": 25090, "locPrf": 100,
"metric": 25090,
"localpref": 100,
"weight": 100,
"valid": true, "valid": true,
"community": { "community": {
"string": "174:21001 174:22003 14525:0 14525:40 14525:1021 14525:2840 14525:3003 14525:4004 14525:9001", "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", "origin": "IGP",
"med": 0, "med": 0,
"metric": 0, "metric": 0,
"localpref": 150, "locPrf": 150,
"weight": 200, "weight": 200,
"valid": true, "valid": true,
"bestpath": { "bestpath": {
@ -124,7 +119,7 @@
"origin": "IGP", "origin": "IGP",
"med": 0, "med": 0,
"metric": 0, "metric": 0,
"localpref": 100, "locPrf": 100,
"weight": 100, "weight": 100,
"valid": true, "valid": true,
"bestpath": { "bestpath": {
@ -180,7 +175,7 @@
"origin": "IGP", "origin": "IGP",
"med": 2020, "med": 2020,
"metric": 2020, "metric": 2020,
"localpref": 150, "locPrf": 150,
"weight": 200, "weight": 200,
"valid": true, "valid": true,
"bestpath": { "bestpath": {

View file

@ -19,7 +19,7 @@ TARGET_FORMAT_SPACE = ("huawei", "huawei_vrpv8")
TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") 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") CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml")

View file

@ -15,6 +15,7 @@ __all__ = (
"FRRouting_BGPRoute", "FRRouting_BGPRoute",
"FRRouting_Ping", "FRRouting_Ping",
"FRRouting_Traceroute", "FRRouting_Traceroute",
"FRRouting_BGPRouteTable",
) )
NAME = "FRRouting" NAME = "FRRouting"
@ -36,6 +37,7 @@ FRRouting_BGPRoute = BuiltinDirective(
), ),
], ],
field=Text(description="IP Address, Prefix, or Hostname"), field=Text(description="IP Address, Prefix, or Hostname"),
table_output="__hyperglass_frr_bgp_route_table__",
platforms=PLATFORMS, platforms=PLATFORMS,
) )
@ -110,3 +112,24 @@ FRRouting_Traceroute = BuiltinDirective(
field=Text(description="IP Address, Prefix, or Hostname"), field=Text(description="IP Address, Prefix, or Hostname"),
platforms=PLATFORMS, 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,
)

View file

@ -14,7 +14,7 @@ from hyperglass.models.data import BGPRouteTable
# Local # Local
from ..main import HyperglassModel from ..main import HyperglassModel
FRRPeerType = t.Literal["internal", "external"] FRRPeerType = t.Literal["internal", "external", "confed-internal", "confed-external"]
def _alias_generator(field): def _alias_generator(field):
@ -48,11 +48,12 @@ class FRRPath(_FRRBase):
"""FRR Path Model.""" """FRR Path Model."""
aspath: t.List[int] aspath: t.List[int]
aggregator_as: int aggregator_as: int = 0
aggregator_id: str aggregator_id: str = ""
loc_prf: int = 100 # 100 is the default value for local preference
metric: int = 0
med: int = 0 med: int = 0
localpref: int weight: int = 0
weight: int
valid: bool valid: bool
last_update: int last_update: int
bestpath: bool bestpath: bool
@ -60,25 +61,26 @@ class FRRPath(_FRRBase):
nexthops: t.List[FRRNextHop] nexthops: t.List[FRRNextHop]
peer: FRRPeer peer: FRRPeer
@model_validator(pre=True) @model_validator(mode="before")
def validate_path(cls, values): def validate_path(cls, values):
"""Extract meaningful data from FRR response.""" """Extract meaningful data from FRR response."""
new = values.copy() new = values.copy()
new["aspath"] = values["aspath"]["segments"][0]["list"] 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"] new["lastUpdate"] = values["lastUpdate"]["epoch"]
bestpath = values.get("bestpath", {}) bestpath = values.get("bestpath", {})
new["bestpath"] = bestpath.get("overall", False) new["bestpath"] = bestpath.get("overall", False)
return new return new
class FRRRoute(_FRRBase): class FRRBGPTable(_FRRBase):
"""FRR Route Model.""" """FRR Route Model."""
prefix: str prefix: str
paths: t.List[FRRPath] = [] paths: t.List[FRRPath] = []
def serialize(self): def bgp_table(self):
"""Convert the FRR-specific fields to standard parsed data model.""" """Convert the FRR-specific fields to standard parsed data model."""
# TODO: somehow, get the actual VRF # TODO: somehow, get the actual VRF
@ -96,7 +98,7 @@ class FRRRoute(_FRRBase):
"age": age, "age": age,
"weight": route.weight, "weight": route.weight,
"med": route.med, "med": route.med,
"local_preference": route.localpref, "local_preference": route.loc_prf,
"as_path": route.aspath, "as_path": route.aspath,
"communities": route.community, "communities": route.community,
"next_hop": route.nexthops[0].ip, "next_hop": route.nexthops[0].ip,
@ -104,6 +106,7 @@ class FRRRoute(_FRRBase):
"source_rid": route.aggregator_id, "source_rid": route.aggregator_id,
"peer_rid": route.peer.peer_id, "peer_rid": route.peer.peer_id,
# TODO: somehow, get the actual RPKI state # TODO: somehow, get the actual RPKI state
# This depends on whether or not the RPKI module is enabled in FRR
"rpki_state": 3, "rpki_state": 3,
} }
) )

View file

@ -3,11 +3,13 @@
# Local # Local
from .remove_command import RemoveCommand from .remove_command import RemoveCommand
from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_arista import BGPRoutePluginArista
from .bgp_route_frr import BGPRoutePluginFrr
from .bgp_route_juniper import BGPRoutePluginJuniper from .bgp_route_juniper import BGPRoutePluginJuniper
from .mikrotik_garbage_output import MikrotikGarbageOutput from .mikrotik_garbage_output import MikrotikGarbageOutput
__all__ = ( __all__ = (
"BGPRoutePluginArista", "BGPRoutePluginArista",
"BGPRoutePluginFrr",
"BGPRoutePluginJuniper", "BGPRoutePluginJuniper",
"MikrotikGarbageOutput", "MikrotikGarbageOutput",
"RemoveCommand", "RemoveCommand",

View file

@ -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

View file

@ -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)