diff --git a/hyperglass/constants.py b/hyperglass/constants.py index a297b3f..f7d372a 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",) +SUPPORTED_STRUCTURED_OUTPUT = ("juniper", "arista_eos") STATUS_CODE_MAP = {"warning": 400, "error": 400, "danger": 500} diff --git a/hyperglass/models/commands/arista_eos.py b/hyperglass/models/commands/arista_eos.py index 7d5207b..29dbc3a 100644 --- a/hyperglass/models/commands/arista_eos.py +++ b/hyperglass/models/commands/arista_eos.py @@ -47,6 +47,38 @@ class _VPNIPv6(CommandSet): traceroute: StrictStr = "traceroute vrf {vrf} ipv6 {target} source {source}" +_structured = CommandGroup( + ipv4_default=CommandSet( + bgp_route="show ip bgp {target} | json", + bgp_aspath="show ip bgp regexp {target} | json", + bgp_community="show ip bgp community {target} | json", + ping="ping ip {target} source {source}", + traceroute="traceroute ip {target} source {source}", + ), + ipv6_default=CommandSet( + bgp_route="show ipv6 bgp {target} | json", + bgp_aspath="show ipv6 bgp regexp {target} | json", + bgp_community="show ipv6 bgp community {target} | json", + ping="ping ipv6 {target} source {source}", + traceroute="traceroute ipv6 {target} source {source}", + ), + ipv4_vpn=CommandSet( + bgp_route="show ip bgp {target} vrf {vrf} | json", + bgp_aspath="show ip bgp regexp {target} vrf {vrf} | json", + bgp_community="show ip bgp community {target} vrf {vrf} | json", + ping="ping vrf {vrf} ip {target} source {source}", + traceroute="traceroute vrf {vrf} ip {target} source {source}", + ), + ipv6_vpn=CommandSet( + bgp_route="show ipv6 bgp {target} vrf {vrf} | json", + bgp_aspath="show ipv6 bgp regexp {target} vrf {vrf} | json", + bgp_community="show ipv6 bgp community {target} vrf {vrf} | json", + ping="ping vrf {vrf} ipv6 {target} source {source}", + traceroute="traceroute vrf {vrf} ipv6 {target} source {source}", + ), +) + + class AristaEOSCommands(CommandGroup): """Validation model for default arista_eos commands.""" @@ -54,3 +86,8 @@ class AristaEOSCommands(CommandGroup): ipv6_default: _IPv6 = _IPv6() ipv4_vpn: _VPNIPv4 = _VPNIPv4() ipv6_vpn: _VPNIPv6 = _VPNIPv6() + + def __init__(self, **kwargs): + """Initialize command group, ensure structured fields are not overridden.""" + super().__init__(**kwargs) + self.structured = _structured diff --git a/hyperglass/models/parsing/arista_eos.py b/hyperglass/models/parsing/arista_eos.py new file mode 100644 index 0000000..53c8cf9 --- /dev/null +++ b/hyperglass/models/parsing/arista_eos.py @@ -0,0 +1,155 @@ +"""Data Models for Parsing Arista JSON Response.""" + +# Standard Library +from typing import Dict, List, Optional +from datetime import datetime + +# Project +from hyperglass.log import log + +# Local +from ..main import HyperglassModel +from .serialized import ParsedRoutes + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notFound": 2, + "notValidated": 3, +} + +WINNING_WEIGHT = "high" + + +def _alias_generator(field: str) -> str: + caps = "".join(x for x in field.title() if x.isalnum()) + return caps[0].lower() + caps[1:] + + +class _AristaBase(HyperglassModel): + """Base Model for Arista validation.""" + + class Config: + extra = "ignore" + alias_generator = _alias_generator + + +class AristaAsPathEntry(_AristaBase): + """Validation model for Arista asPathEntry.""" + + as_path_type: str = "External" + as_path: str = "" + + +class AristaPeerEntry(_AristaBase): + """Validation model for Arista peerEntry.""" + + peer_router_id: str + peer_addr: str + + +class AristaRouteType(_AristaBase): + """Validation model for Arista routeType.""" + + origin: str + suppressed: bool + valid: bool + active: bool + origin_validity: Optional[str] = "notVerified" + + +class AristaRouteDetail(_AristaBase): + """Validation for Arista routeDetail.""" + + origin: str + label_stack: List = [] + ext_community_list: List[str] = [] + ext_community_list_raw: List[str] = [] + community_list: List[str] = [] + large_community_list: List[str] = [] + + +class AristaRoutePath(_AristaBase): + """Validation model for Arista bgpRoutePaths.""" + + as_path_entry: AristaAsPathEntry + med: int + local_preference: int + weight: int + peer_entry: AristaPeerEntry + reason_not_bestpath: str + timestamp: int = int(datetime.utcnow().timestamp()) + next_hop: str + route_type: AristaRouteType + route_detail: Optional[AristaRouteDetail] + + +class AristaRouteEntry(_AristaBase): + """Validation model for Arista bgpRouteEntries.""" + + total_paths: int = 0 + bgp_advertised_peer_groups: Dict = {} + mask_length: int + bgp_route_paths: List[AristaRoutePath] = [] + + +class AristaRoute(_AristaBase): + """Validation model for Arista bgpRouteEntries data.""" + + router_id: str + vrf: str + bgp_route_entries: Dict[str, AristaRouteEntry] + + @staticmethod + def _get_route_age(timestamp: int) -> int: + now = datetime.utcnow() + now_timestamp = int(now.timestamp()) + return now_timestamp - timestamp + + @staticmethod + def _get_as_path(as_path: str) -> List[str]: + return [int(p) for p in as_path.split() if p.isdecimal()] + + def serialize(self): + """Convert the Arista-formatted fields to standard parsed data model.""" + routes = [] + count = 0 + for prefix, entries in self.bgp_route_entries.items(): + + count += entries.total_paths + + for route in entries.bgp_route_paths: + + as_path = self._get_as_path(route.as_path_entry.as_path) + rpki_state = RPKI_STATE_MAP.get(route.route_type.origin_validity, 3) + + # BGP AS Path and BGP Community queries do not include the routeDetail + # block. Therefore, we must verify it exists before including its data. + communities = [] + if route.route_detail is not None: + communities = route.route_detail.community_list + + routes.append( + { + "prefix": prefix, + "active": route.route_type.active, + "age": self._get_route_age(route.timestamp), + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": as_path, + "communities": communities, + "next_hop": route.next_hop, + "source_as": as_path[0], + "source_rid": route.peer_entry.peer_router_id, + "peer_rid": route.peer_entry.peer_router_id, + "rpki_state": rpki_state, + } + ) + + serialized = ParsedRoutes( + vrf=self.vrf, count=count, routes=routes, winning_weight=WINNING_WEIGHT, + ) + + log.debug("Serialized Arista response: {}", serialized) + return serialized diff --git a/hyperglass/models/parsing/arista_route.json b/hyperglass/models/parsing/arista_route.json new file mode 100644 index 0000000..cde5965 --- /dev/null +++ b/hyperglass/models/parsing/arista_route.json @@ -0,0 +1,65 @@ +{ + "vrfs": { + "default": { + "routerId": "198.18.0.1", + "vrf": "default", + "bgpRouteEntries": { + "198.18.2.0/24": { + "totalPaths": 1, + "bgpAdvertisedPeerGroups": {}, + "maskLength": 24, + "bgpRoutePaths": [ + { + "asPathEntry": { + "asPathType": "External", + "asPath": "65002 65003" + }, + "med": 8675309, + "localPreference": 100, + "weight": 0, + "peerEntry": { + "peerRouterId": "198.18.0.2", + "peerAddr": "198.18.0.2" + }, + "reasonNotBestpath": "noReason", + "timestamp": 1612996830, + "nextHop": "198.18.0.2", + "routeType": { + "atomicAggregator": false, + "origin": "Igp", + "suppressed": false, + "queued": false, + "valid": true, + "ecmpContributor": false, + "luRoute": false, + "active": true, + "stale": false, + "ecmp": false, + "backup": false, + "ecmpHead": false, + "ucmp": false + }, + "routeDetail": { + "origin": "Igp", + "labelStack": [], + "isLeaked": false, + "tunnelRibEligible": false, + "extCommunityList": [], + "extCommunityListRaw": [], + "communityList": ["65002:1"], + "rxSafi": "Unicast", + "bgpContributors": [], + "recvdFromRRClient": false, + "igpMetric": 1, + "largeCommunityList": [], + "domainPath": [] + } + } + ], + "address": "198.18.2.0" + } + }, + "asn": "65001" + } + } +} diff --git a/hyperglass/parsing/arista.py b/hyperglass/parsing/arista.py new file mode 100644 index 0000000..e5cdb79 --- /dev/null +++ b/hyperglass/parsing/arista.py @@ -0,0 +1,53 @@ +"""Parse Arista JSON Response to Structured Data.""" + +# Standard Library +import json +from typing import Dict, Sequence + +# Third Party +from pydantic import ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions import ParsingError +from hyperglass.models.parsing.arista_eos import AristaRoute + + +def parse_arista(output: Sequence[str]) -> Dict: # noqa: C901 + """Parse a Arista BGP JSON response.""" + data = {} + + for i, response in enumerate(output): + + try: + data: Dict = json.loads(response) + + log.debug("Pre-parsed data: {}", data) + + vrf = list(data["vrfs"].keys())[0] + routes = data["vrfs"][vrf] + + log.debug("Pre-validated data: {}", routes) + + validated = AristaRoute(**routes) + serialized = validated.serialize().export_dict() + + if i == 0: + data.update(serialized) + else: + data["routes"].extend(serialized["routes"]) + + except json.JSONDecodeError as err: + log.critical("Error decoding JSON: {}", str(err)) + raise ParsingError("Error parsing response data") + + except (KeyError, IndexError) as err: + log.critical("{} was not found in the response", str(err)) + raise ParsingError("Error parsing response data") + + except ValidationError as err: + log.critical(str(err)) + raise ParsingError(err.errors()) + + log.debug("Serialzed: {}", data) + return data diff --git a/hyperglass/parsing/nos.py b/hyperglass/parsing/nos.py index 1da7e2b..eefed69 100644 --- a/hyperglass/parsing/nos.py +++ b/hyperglass/parsing/nos.py @@ -1,6 +1,7 @@ """Map NOS and Commands to Parsing Functions.""" # Local +from .arista import parse_arista from .juniper import parse_juniper from .mikrotik import parse_mikrotik @@ -10,6 +11,11 @@ structured_parsers = { "bgp_aspath": parse_juniper, "bgp_community": parse_juniper, }, + "arista_eos": { + "bgp_route": parse_arista, + "bgp_aspath": parse_arista, + "bgp_community": parse_arista, + }, } scrape_parsers = { diff --git a/hyperglass/parsing/test_arista.py b/hyperglass/parsing/test_arista.py new file mode 100644 index 0000000..218fec3 --- /dev/null +++ b/hyperglass/parsing/test_arista.py @@ -0,0 +1,25 @@ +"""Test Arista JSON Parsing.""" + +# Standard Library +import sys +import json +from pathlib import Path + +# Project +from hyperglass.log import log + +# Local +from .arista import parse_arista + +SAMPLE_FILE = Path(__file__).parent.parent / "models" / "parsing" / "arista_route.json" + +if __name__ == "__main__": + if len(sys.argv) == 2: + sample = sys.argv[1] + else: + with SAMPLE_FILE.open("r") as file: + sample = file.read() + + parsed = parse_arista([sample]) + log.info(json.dumps(parsed, indent=2)) + sys.exit(0)