From c8892f43eafa19bdb7f90538e99b3190b64758fc Mon Sep 17 00:00:00 2001 From: thatmattlove Date: Wed, 8 Dec 2021 17:13:56 -0700 Subject: [PATCH] Implement Arista table output plugin and default directive --- hyperglass/defaults/directives/arista_eos.py | 165 ++++++++++++++++++ hyperglass/models/parsing/arista_eos.py | 6 +- hyperglass/plugins/_builtin/__init__.py | 2 + .../plugins/_builtin/bgp_route_arista.py | 91 ++++++++++ .../plugins/tests/test_bgp_route_arista.py | 57 ++++++ 5 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 hyperglass/defaults/directives/arista_eos.py create mode 100644 hyperglass/plugins/_builtin/bgp_route_arista.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_arista.py diff --git a/hyperglass/defaults/directives/arista_eos.py b/hyperglass/defaults/directives/arista_eos.py new file mode 100644 index 0000000..31cc995 --- /dev/null +++ b/hyperglass/defaults/directives/arista_eos.py @@ -0,0 +1,165 @@ +"""Default Arista Directives.""" + +# Project +from hyperglass.models.directive import Rule, Text, BuiltinDirective + +__all__ = ( + "AristaBGPRoute", + "AristaBGPASPath", + "AristaBGPCommunity", + "AristaPing", + "AristaTraceroute", + "AristaBGPRouteTable", + "AristaBGPASPathTable", + "AristaBGPCommunityTable", +) + +AristaBGPRoute = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_route__", + name="BGP Route", + rules=[ + Rule( + condition="0.0.0.0/0", + action="permit", + command="show ip bgp {target}", + ), + Rule( + condition="::/0", + action="permit", + command="show ipv6 bgp {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + table_output="__hyperglass_arista_eos_bgp_route_table__", + platforms=["arista_eos"], +) + +AristaBGPASPath = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_aspath__", + name="BGP AS Path", + rules=[ + Rule( + condition="*", + action="permit", + commands=[ + "show ip bgp regexp {target}", + "show ipv6 bgp regexp {target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_arista_eos_bgp_aspath_table__", + platforms=["arista_eos"], +) + +AristaBGPCommunity = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_community__", + name="BGP Community", + rules=[ + Rule( + condition="*", + action="permit", + commands=[ + "show ip bgp community {target}", + "show ipv6 bgp community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + table_output="__hyperglass_arista_eos_bgp_community_table__", + platforms=["arista_eos"], +) + + +AristaPing = BuiltinDirective( + id="__hyperglass_arista_eos_ping__", + name="Ping", + rules=[ + Rule( + condition="0.0.0.0/0", + action="permit", + command="ping ip {target} source {source4}", + ), + Rule( + condition="::/0", + action="permit", + command="ping ipv6 {target} source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=["arista_eos"], +) + +AristaTraceroute = BuiltinDirective( + id="__hyperglass_arista_eos_traceroute__", + name="Traceroute", + rules=[ + Rule( + condition="0.0.0.0/0", + action="permit", + command="traceroute ip {target} source {source4}", + ), + Rule( + condition="::/0", + action="permit", + command="traceroute ipv6 {target} source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=["arista_eos"], +) + +# Table Output Directives + +AristaBGPRouteTable = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_route_table__", + name="BGP Route", + rules=[ + Rule( + condition="0.0.0.0/0", + action="permit", + command="show ip bgp {target} | json", + ), + Rule( + condition="::/0", + action="permit", + command="show ipv6 bgp {target} | json", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=["arista_eos"], +) + +AristaBGPASPathTable = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + Rule( + condition="*", + action="permit", + commands=[ + "show ip bgp regexp {target} | json", + "show ipv6 bgp regexp {target} | json", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=["arista_eos"], +) + +AristaBGPCommunityTable = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_community_table__", + name="BGP Community", + rules=[ + Rule( + condition="*", + action="permit", + commands=[ + "show ip bgp community {target} | json", + "show ipv6 bgp community {target} | json", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=["arista_eos"], +) diff --git a/hyperglass/models/parsing/arista_eos.py b/hyperglass/models/parsing/arista_eos.py index 3a04ce6..e2a6221 100644 --- a/hyperglass/models/parsing/arista_eos.py +++ b/hyperglass/models/parsing/arista_eos.py @@ -93,7 +93,7 @@ class AristaRouteEntry(_AristaBase): bgp_route_paths: List[AristaRoutePath] = [] -class AristaRoute(_AristaBase): +class AristaBGPTable(_AristaBase): """Validation model for Arista bgpRouteEntries data.""" router_id: str @@ -114,7 +114,7 @@ class AristaRoute(_AristaBase): return [] return [int(p) for p in as_path.split() if p.isdecimal()] - def serialize(self): + def bgp_table(self: "AristaBGPTable") -> "BGPRouteTable": """Convert the Arista-formatted fields to standard parsed data model.""" routes = [] count = 0 @@ -164,5 +164,5 @@ class AristaRoute(_AristaBase): winning_weight=WINNING_WEIGHT, ) - log.debug("Serialized Arista response: {}", serialized) + log.debug("Serialized Arista response: {!r}", serialized) return serialized diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index fc2da4f..75ebd6e 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -2,9 +2,11 @@ # Local from .remove_command import RemoveCommand +from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_juniper import BGPRoutePluginJuniper __all__ = ( "RemoveCommand", "BGPRoutePluginJuniper", + "BGPRoutePluginArista", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_arista.py b/hyperglass/plugins/_builtin/bgp_route_arista.py new file mode 100644 index 0000000..df29e98 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_arista.py @@ -0,0 +1,91 @@ +"""Parse Arista 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.arista_eos import AristaBGPTable + +# 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_arista(output: t.Sequence[str]) -> "OutputDataModel": + """Parse a Arista BGP JSON response.""" + result = None + + for response in output: + + try: + parsed: t.Dict = json.loads(response) + + log.debug("Pre-parsed data:\n{}", parsed) + + vrf = list(parsed["vrfs"].keys())[0] + routes = parsed["vrfs"][vrf] + + validated = AristaBGPTable(**routes) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except json.JSONDecodeError as err: + log.critical("Error decoding JSON: {}", str(err)) + raise ParsingError("Error parsing response data") + + except KeyError as err: + log.critical("'{}' was not found in the response", str(err)) + raise ParsingError("Error parsing response data") + + except IndexError as err: + log.critical(str(err)) + raise ParsingError("Error parsing response data") + + except ValidationError as err: + log.critical(str(err)) + raise ParsingError(err.errors()) + + return result + + +class BGPRoutePluginArista(OutputPlugin): + """Coerce a Arista route table in JSON format to a standard BGP Table structure.""" + + __hyperglass_builtin__: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("arista_eos",) + directives: t.Sequence[str] = ( + "__hyperglass_arista_eos_bgp_route_table__", + "__hyperglass_arista_eos_bgp_aspath_table__", + "__hyperglass_arista_eos_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Arista 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_arista(output) + return output diff --git a/hyperglass/plugins/tests/test_bgp_route_arista.py b/hyperglass/plugins/tests/test_bgp_route_arista.py new file mode 100644 index 0000000..65ecd15 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_arista.py @@ -0,0 +1,57 @@ +"""Arista 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 .._builtin.bgp_route_arista import BGPRoutePluginArista + +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" / "arista_route.json" + + +def _tester(sample: str): + plugin = BGPRoutePluginArista() + + device = Device( + name="Test Device", + address="127.0.0.1", + group="Test Network", + credential={"username": "", "password": ""}, + platform="arista", + structured_output=True, + directives=[], + attrs={"source4": "192.0.2.1", "source6": "2001:db8::1"}, + ) + + # Override has_directives method for testing. + device.has_directives = lambda *x: True + + 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_arista_route_sample(): + with SAMPLE.open("r") as file: + sample = file.read() + return _tester(sample)