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:
parent
7ae40e8cc8
commit
51c7f9eef6
7 changed files with 184 additions and 20 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
86
hyperglass/plugins/_builtin/bgp_route_frr.py
Normal file
86
hyperglass/plugins/_builtin/bgp_route_frr.py
Normal 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
|
||||||
55
hyperglass/plugins/tests/test_bgp_route_frr.py
Normal file
55
hyperglass/plugins/tests/test_bgp_route_frr.py
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue