diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index edc2c0a..63e4965 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: node-version: [20.x] - pnpm-version: [8] + pnpm-version: [9] redis-version: [latest] python-version: ["3.11", "3.12"] os: [ubuntu-latest] @@ -23,6 +23,11 @@ jobs: - name: Git Checkout uses: actions/checkout@v3 + - name: Install system pachages + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config python3-dev + - name: Install Python uses: actions/setup-python@v5 with: @@ -48,7 +53,6 @@ jobs: - name: Prepare run: | - sudo apt-get install libcairo2-dev -y mkdir -p "$HOME/hyperglass" echo "HYPERGLASS_APP_PATH=$HOME/hyperglass" >> $GITHUB_ENV echo "HYPERGLASS_HOST=127.0.0.1" >> $GITHUB_ENV diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 55bf46f..e97859f 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -12,8 +12,8 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20.x] - pnpm-version: [8] + node-version: [20.x, 21.x, 22.x] + pnpm-version: [9, 10] os: [ubuntu-latest] runs-on: ${{ matrix.os }} env: 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/CHANGELOG.md b/CHANGELOG.md index b17f6cc..692344c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- [#280](https://github.com/thatmattlove/hyperglass/issues/280): Fix: `condition: None` caused error in directive +- [#306](https://github.com/thatmattlove/hyperglass/issues/306): Fix: allow integer values in ext_community_list_raw field for Arista BGP - @cooperwinser +- [#325](https://github.com/thatmattlove/hyperglass/pull/325): Fix code block padding in the documentation - @jagardaniel +- [#327](https://github.com/thatmattlove/hyperglass/pull/327): Fix huawei bgp route and plugin validation/transform order - @JelsonRodrigues + +### Updated + +- [#245](https://github.com/thatmattlove/hyperglass/issues/245): v2.0.0 Vyos version platforms - Moved to latest LTS command set. - @ServerForge +- [#292](https://github.com/thatmattlove/hyperglass/pull/292): Updates Mikrotik BGP route command so supernets are selected as well as exact matches. - @GrandArcher + +### Added + +- [#304](https://github.com/thatmattlove/hyperglass/pull/304): Add FRR structured output for BGP Routes - @chriswiggins + ## 2.0.4 - 2024-06-30 ### Fixed diff --git a/docs/pages/installation/manual.mdx b/docs/pages/installation/manual.mdx index ef17b03..4c3cefc 100644 --- a/docs/pages/installation/manual.mdx +++ b/docs/pages/installation/manual.mdx @@ -13,7 +13,7 @@ To install hyperglass manually, you'll need to install the following dependencie 1. [Python 3.11, or 3.12](https://www.python.org/downloads/) and [`pip`](https://pip.pypa.io/en/stable/installation/) 2. [NodeJS 20.14 or later](https://nodejs.org/en/download) -3. [PNPM 8 or later](https://pnpm.io/installation) +3. [PNPM 9 or later](https://pnpm.io/installation) 4. [Redis 7.2 or later](https://redis.io/download/) Make sure the Redis server is started. @@ -25,6 +25,8 @@ Once these dependencies are installed, install hyperglass via PyPI: ```shell copy git clone https://github.com/thatmattlove/hyperglass --depth=1 cd hyperglass +# optional - switch to the latest stable release +# git switch -c v2.0.4 v2.0.4 pip3 install -e . ``` diff --git a/docs/pages/plugins.mdx b/docs/pages/plugins.mdx index 2f256f8..adc5087 100644 --- a/docs/pages/plugins.mdx +++ b/docs/pages/plugins.mdx @@ -60,13 +60,13 @@ ip_route_directive: When the query is received, the query target is transformed, resulting in this being sent to the device: -``` +```text show ip route 192.0.2.0 255.255.255.0 ``` instead of: -``` +```text show ip route 192.0.2.0/24 ``` diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py index 5684629..65458f9 100644 --- a/hyperglass/api/__init__.py +++ b/hyperglass/api/__init__.py @@ -1,4 +1,5 @@ """hyperglass API.""" + # Standard Library import logging diff --git a/hyperglass/cli/echo.py b/hyperglass/cli/echo.py index 642c1ce..4f7ced0 100644 --- a/hyperglass/cli/echo.py +++ b/hyperglass/cli/echo.py @@ -1,4 +1,5 @@ """Helper functions for CLI message printing.""" + # Standard Library import typing as t diff --git a/hyperglass/cli/main.py b/hyperglass/cli/main.py index 2ee2107..7cc06f6 100644 --- a/hyperglass/cli/main.py +++ b/hyperglass/cli/main.py @@ -33,7 +33,7 @@ def run(): def _version( version: t.Optional[bool] = typer.Option( None, "--version", help="hyperglass version", callback=_version - ) + ), ) -> None: """hyperglass""" pass @@ -77,7 +77,6 @@ def _build_ui(timeout: int = typer.Option(180, help="Timeout in seconds")) -> No with echo._console.status( f"Starting new UI build with a {timeout} second timeout...", spinner="aesthetic" ): - _build_ui(timeout=120) @@ -140,7 +139,7 @@ def _clear_cache(): @cli.command(name="devices") def _devices( - search: t.Optional[str] = typer.Argument(None, help="Device ID or Name Search Pattern") + search: t.Optional[str] = typer.Argument(None, help="Device ID or Name Search Pattern"), ): """Show all configured devices""" # Third Party @@ -189,7 +188,7 @@ def _devices( @cli.command(name="directives") def _directives( - search: t.Optional[str] = typer.Argument(None, help="Directive ID or Name Search Pattern") + search: t.Optional[str] = typer.Argument(None, help="Directive ID or Name Search Pattern"), ): """Show all configured devices""" # Third Party @@ -280,7 +279,7 @@ def _plugins( def _params( path: t.Optional[str] = typer.Argument( None, help="Parameter Object Path, for example 'messages.no_input'" - ) + ), ): """Show configuration parameters""" # Standard Library @@ -312,7 +311,7 @@ def _params( ) raise typer.Exit(0) except AttributeError: - echo.error(f"{'params.'+path!r} does not exist") + echo.error(f"{'params.' + path!r} does not exist") raise typer.Exit(1) panel = Inspect( 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/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index b517503..f8d3ef0 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -27,15 +27,16 @@ Huawei_BGPRoute = BuiltinDirective( RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="display bgp routing-table {target}", + command="display bgp routing-table {target} | no-more", ), RuleWithIPv6( condition="::/0", action="permit", - command="display bgp ipv6 routing-table {target}", + command="display bgp ipv6 routing-table {target} | no-more", ), ], field=Text(description="IP Address, Prefix, or Hostname"), + plugins=["bgp_route_huawei"], platforms=PLATFORMS, ) diff --git a/hyperglass/defaults/directives/mikrotik.py b/hyperglass/defaults/directives/mikrotik.py index 16c948a..4ea96fd 100644 --- a/hyperglass/defaults/directives/mikrotik.py +++ b/hyperglass/defaults/directives/mikrotik.py @@ -27,12 +27,12 @@ Mikrotik_BGPRoute = BuiltinDirective( RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="ip route print where dst-address={target}", + command="ip route print where {target} in dst-address", ), RuleWithIPv6( condition="::/0", action="permit", - command="ipv6 route print where dst-address={target}", + command="ipv6 route print where {target} in dst-address", ), ], field=Text(description="IP Address, Prefix, or Hostname"), diff --git a/hyperglass/defaults/directives/vyos.py b/hyperglass/defaults/directives/vyos.py index fcfb4ac..6aec00a 100644 --- a/hyperglass/defaults/directives/vyos.py +++ b/hyperglass/defaults/directives/vyos.py @@ -27,12 +27,12 @@ VyOS_BGPRoute = BuiltinDirective( RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="show ip bgp {target}", + command="show bgp ipv4 {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="show ipv6 bgp {target}", + command="show bgp ipv6 {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), @@ -47,8 +47,8 @@ VyOS_BGPASPath = BuiltinDirective( condition="*", action="permit", commands=[ - 'show ip bgp regexp "{target}"', - 'show ipv6 bgp regexp "{target}"', + 'show bgp ipv4 regexp "{target}"', + 'show bgp ipv6 regexp "{target}"', ], ) ], @@ -64,8 +64,8 @@ VyOS_BGPCommunity = BuiltinDirective( condition="*", action="permit", commands=[ - "show ip bgp community {target}", - "show ipv6 bgp community {target}", + "show bgp ipv4 community {target}", + "show bgp ipv6 community {target}", ], ) ], @@ -99,12 +99,12 @@ VyOS_Traceroute = BuiltinDirective( RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="mtr -4 -G 1 -c 1 -w -o SAL -a {source4} {target}", + command="traceroute {target} source-address {source4} icmp", ), RuleWithIPv6( condition="::/0", action="permit", - command="mtr -6 -G 1 -c 1 -w -o SAL -a {source6} {target}", + command="traceroute {target} source-address {source6} icmp", ), ], field=Text(description="IP Address, Prefix, or Hostname"), diff --git a/hyperglass/exceptions/_common.py b/hyperglass/exceptions/_common.py index 9963324..1f04f94 100644 --- a/hyperglass/exceptions/_common.py +++ b/hyperglass/exceptions/_common.py @@ -2,7 +2,7 @@ # Standard Library import json as _json -from typing import Any, Dict, List, Union, Literal, Optional, Set +from typing import Any, Set, Dict, List, Union, Literal, Optional # Third Party from pydantic import ValidationError @@ -72,7 +72,7 @@ class HyperglassError(Exception): for err in errors: loc = " → ".join(str(loc) for loc in err["loc"]) - errs += (f'Field: {loc}\n Error: {err["msg"]}\n',) + errs += (f"Field: {loc}\n Error: {err['msg']}\n",) return "\n".join(errs) diff --git a/hyperglass/execution/drivers/_construct.py b/hyperglass/execution/drivers/_construct.py index 86032bc..c1d1e2b 100644 --- a/hyperglass/execution/drivers/_construct.py +++ b/hyperglass/execution/drivers/_construct.py @@ -94,7 +94,7 @@ class Construct: for key in [k for k in keys if k != "target" and k != "mask"]: if key not in attrs: raise ConfigError( - ("Command '{c}' has attribute '{k}', " "which is missing from device '{d}'"), + ("Command '{c}' has attribute '{k}', which is missing from device '{d}'"), level="danger", c=self.directive.name, k=key, @@ -224,4 +224,4 @@ class Formatter: def _bird_bgp_community(self, target: str) -> str: """Convert from standard community format to BIRD format.""" parts = target.split(":") - return f'({",".join(parts)})' + return f"({','.join(parts)})" diff --git a/hyperglass/execution/drivers/ssh.py b/hyperglass/execution/drivers/ssh.py index 13e070e..25d81b4 100644 --- a/hyperglass/execution/drivers/ssh.py +++ b/hyperglass/execution/drivers/ssh.py @@ -44,9 +44,9 @@ class SSHConnection(Connection): if proxy.credential._method == "encrypted_key": # If the key is encrypted, use the password field as the # private key password. - tunnel_kwargs[ - "ssh_private_key_password" - ] = proxy.credential.password.get_secret_value() + tunnel_kwargs["ssh_private_key_password"] = ( + proxy.credential.password.get_secret_value() + ) try: return open_tunnel(proxy._target, proxy.port, **tunnel_kwargs) diff --git a/hyperglass/external/_base.py b/hyperglass/external/_base.py index 60c35da..3954c51 100644 --- a/hyperglass/external/_base.py +++ b/hyperglass/external/_base.py @@ -212,7 +212,7 @@ class BaseExternal: if method.upper() not in supported_methods: raise self._exception( - f'Method must be one of {", ".join(supported_methods)}. ' f"Got: {str(method)}" + f"Method must be one of {', '.join(supported_methods)}. Got: {str(method)}" ) endpoint = "/".join( @@ -284,7 +284,7 @@ class BaseExternal: status = httpx.codes(response.status_code) error = self._parse_response(response) raise self._exception( - f'{status.name.replace("_", " ")}: {error}', level="danger" + f"{status.name.replace('_', ' ')}: {error}", level="danger" ) from None except httpx.HTTPError as http_err: @@ -340,7 +340,7 @@ class BaseExternal: status = httpx.codes(response.status_code) error = self._parse_response(response) raise self._exception( - f'{status.name.replace("_", " ")}: {error}', level="danger" + f"{status.name.replace('_', ' ')}: {error}", level="danger" ) from None except httpx.HTTPError as http_err: diff --git a/hyperglass/external/bgptools.py b/hyperglass/external/bgptools.py index 1ea1cee..f2fd7f6 100644 --- a/hyperglass/external/bgptools.py +++ b/hyperglass/external/bgptools.py @@ -35,7 +35,7 @@ def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.Tuple[str, ...]]: default_data = {} query = () for target in targets: - detail: TargetDetail = {k: "None" for k in DEFAULT_KEYS} + detail: TargetDetail = dict.fromkeys(DEFAULT_KEYS, "None") try: valid: t.Union[IPv4Address, IPv6Address] = ip_address(target) @@ -139,7 +139,7 @@ async def network_info(*targets: str) -> TargetData: cache = use_state("cache") # Set default data structure. - query_data = {t: {k: "" for k in DEFAULT_KEYS} for t in query_targets} + query_data = {t: dict.fromkeys(DEFAULT_KEYS, "") for t in query_targets} # Get all cached bgp.tools data. cached = cache.get_map(CACHE_KEY) or {} diff --git a/hyperglass/external/tests/test_base.py b/hyperglass/external/tests/test_base.py index 5f23594..6604f4e 100644 --- a/hyperglass/external/tests/test_base.py +++ b/hyperglass/external/tests/test_base.py @@ -1,4 +1,5 @@ """Test external http client.""" + # Standard Library import asyncio diff --git a/hyperglass/external/tests/test_bgptools.py b/hyperglass/external/tests/test_bgptools.py index ca8f98d..542c1dc 100644 --- a/hyperglass/external/tests/test_bgptools.py +++ b/hyperglass/external/tests/test_bgptools.py @@ -16,7 +16,6 @@ WHOIS_OUTPUT = """AS | IP | BGP Prefix | CC | Registry | Allocated | AS # Ignore asyncio deprecation warning about loop @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_network_info(): - checks = ( ("192.0.2.1", {"asn": "None", "rir": "Private Address"}), ("127.0.0.1", {"asn": "None", "rir": "Loopback Address"}), diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 66da2e4..01438e7 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -1,4 +1,5 @@ """Test RPKI data fetching.""" + # Third Party import pytest @@ -18,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert ( - result == expected - ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name + assert result == expected, ( + "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name + ) ) diff --git a/hyperglass/models/api/__init__.py b/hyperglass/models/api/__init__.py index 70769f4..8f4bfcd 100644 --- a/hyperglass/models/api/__init__.py +++ b/hyperglass/models/api/__init__.py @@ -1,4 +1,5 @@ """Query & Response Validation Models.""" + # Local from .query import Query from .response import ( diff --git a/hyperglass/models/api/query.py b/hyperglass/models/api/query.py index d199214..a8791bc 100644 --- a/hyperglass/models/api/query.py +++ b/hyperglass/models/api/query.py @@ -71,13 +71,13 @@ class Query(BaseModel): self._input_plugin_manager = InputPluginManager() - self.query_target = self.transform_query_target() - try: self.validate_query_target() except InputValidationError as err: raise InputInvalid(**err.kwargs) from err + self.query_target = self.transform_query_target() + def summary(self) -> SimpleQuery: """Summarized and post-validated model of a Query.""" return SimpleQuery( diff --git a/hyperglass/models/config/cache.py b/hyperglass/models/config/cache.py index 0871b94..f3c3e93 100644 --- a/hyperglass/models/config/cache.py +++ b/hyperglass/models/config/cache.py @@ -1,6 +1,5 @@ """Validation model for cache config.""" - # Local from ..main import HyperglassModel diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index dc417ed..a00af4e 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -6,7 +6,7 @@ import typing as t from ipaddress import ip_network # Third Party -from pydantic import field_validator, ValidationInfo +from pydantic import ValidationInfo, field_validator # Project from hyperglass.state import use_state diff --git a/hyperglass/models/directive.py b/hyperglass/models/directive.py index df6d7d8..c88c2c1 100644 --- a/hyperglass/models/directive.py +++ b/hyperglass/models/directive.py @@ -19,7 +19,7 @@ from .main import MultiModel, HyperglassModel, HyperglassUniqueModel from .fields import Action StringOrArray = t.Union[str, t.List[str]] -Condition = t.Union[IPvAnyNetwork, str] +Condition = t.Union[str, None] RuleValidation = t.Union[t.Literal["ipv4", "ipv6", "pattern"], None] PassedValidation = t.Union[bool, None] IPFamily = t.Literal["ipv4", "ipv6"] @@ -264,7 +264,7 @@ class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): id: str name: str rules: t.List[RuleType] = [RuleWithoutValidation()] - field: t.Union[Text, Select] + field: t.Union[Text, Select, None] info: t.Optional[FilePath] = None plugins: t.List[str] = [] table_output: t.Optional[str] = None @@ -291,7 +291,7 @@ class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): out_rules.append(RuleWithIPv6(**rule)) except ValueError: out_rules.append(RuleWithPattern(**rule)) - if isinstance(rule, Rule): + elif isinstance(rule, Rule): out_rules.append(rule) return out_rules @@ -307,7 +307,8 @@ class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): @property def field_type(self) -> t.Literal["text", "select", None]: """Get the linked field type.""" - + if self.field is None: + return None if self.field.is_select: return "select" if self.field.is_text or self.field.is_ip: @@ -338,7 +339,7 @@ class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): "name": self.name, "field_type": self.field_type, "groups": self.groups, - "description": self.field.description, + "description": self.field.description if self.field is not None else '', "info": None, } @@ -346,7 +347,7 @@ class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): with self.info.open() as md: value["info"] = md.read() - if self.field.is_select: + if self.field is not None and self.field.is_select: value["options"] = [o.export_dict() for o in self.field.options if o is not None] return value diff --git a/hyperglass/models/parsing/frr.py b/hyperglass/models/parsing/frr.py index 895046b..dacd82c 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/models/tests/test_util.py b/hyperglass/models/tests/test_util.py index bb133d6..f990d57 100644 --- a/hyperglass/models/tests/test_util.py +++ b/hyperglass/models/tests/test_util.py @@ -19,9 +19,9 @@ def test_check_legacy_fields(): test1_expected.keys() ), "legacy field not replaced" - assert set(check_legacy_fields(model="Device", data=test2).keys()) == set( - test2.keys() - ), "new field not left unmodified" + assert set(check_legacy_fields(model="Device", data=test2).keys()) == set(test2.keys()), ( + "new field not left unmodified" + ) with pytest.raises(ValueError): check_legacy_fields(model="Device", data=test3) diff --git a/hyperglass/models/ui.py b/hyperglass/models/ui.py index 3e2c106..f8a304c 100644 --- a/hyperglass/models/ui.py +++ b/hyperglass/models/ui.py @@ -19,7 +19,7 @@ class UIDirective(HyperglassModel): id: str name: str - field_type: str + field_type: t.Union[str, None] groups: t.List[str] description: str info: t.Optional[str] = None diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 4a36861..5e87f5b 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -1,14 +1,18 @@ """Built-in hyperglass plugins.""" # Local +from .bgp_route_frr import BGPRoutePluginFrr from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista +from .bgp_route_huawei import BGPRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput __all__ = ( "BGPRoutePluginArista", + "BGPRoutePluginFrr", "BGPRoutePluginJuniper", + "BGPRoutePluginHuawei", "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..7130b20 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_frr.py @@ -0,0 +1,85 @@ +"""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/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py new file mode 100644 index 0000000..172d906 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -0,0 +1,47 @@ +# Standard Library +import typing as t +from ipaddress import ip_network + +# Third Party +from pydantic import PrivateAttr + +# Local +from .._input import InputPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.api.query import Query + +InputPluginTransformReturn = t.Union[t.Sequence[str], str] + + +class BGPRoutePluginHuawei(InputPlugin): + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ( + "huawei", + "huawei_vrpv8", + ) + directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + """ + Huawei BGP Route Input Plugin + + This plugin transforms a query target into a network address and prefix length + ex.: 192.0.2.0/24 -> 192.0.2.0 24 + ex.: 2001:db8::/32 -> 2001:db8:: 32 + """ + + def transform(self, query: "Query") -> InputPluginTransformReturn: + target = query.query_target + + if not target or not isinstance(target, list) or len(target) == 0: + return None + + target = target[0].strip() + + # Check for the / in the query target + if target.find("/") == -1: + return target + + target_network = ip_network(target) + + return f"{target_network.network_address!s} {target_network.prefixlen!s}" diff --git a/hyperglass/plugins/tests/test_bgp_community.py b/hyperglass/plugins/tests/test_bgp_community.py index d5f8834..3541244 100644 --- a/hyperglass/plugins/tests/test_bgp_community.py +++ b/hyperglass/plugins/tests/test_bgp_community.py @@ -1,4 +1,5 @@ """Test BGP Community validation.""" + # Standard Library import typing as t 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) diff --git a/hyperglass/state/tests/test_hooks.py b/hyperglass/state/tests/test_hooks.py index aaa9d5c..e82f1a4 100644 --- a/hyperglass/state/tests/test_hooks.py +++ b/hyperglass/state/tests/test_hooks.py @@ -96,7 +96,7 @@ def test_use_state_caching(state): instance = use_state(attr) if i == 0: first = instance - assert isinstance( - instance, model - ), f"{instance!r} is not an instance of '{model.__name__}'" + assert isinstance(instance, model), ( + f"{instance!r} is not an instance of '{model.__name__}'" + ) assert instance == first, f"{instance!r} is not equal to {first!r}" diff --git a/hyperglass/ui/components/looking-glass-form.tsx b/hyperglass/ui/components/looking-glass-form.tsx index 664f8a8..2da4633 100644 --- a/hyperglass/ui/components/looking-glass-form.tsx +++ b/hyperglass/ui/components/looking-glass-form.tsx @@ -78,10 +78,14 @@ export const LookingGlassForm = (): JSX.Element => { [], ); - const directive = useMemo( - () => getDirective(), - [form.queryType, form.queryLocation, getDirective], - ); + const directive = useMemo(() => { + const tmp = getDirective(); + if (tmp !== null && tmp.fieldType === null) { + setFormValue('queryTarget', ['null']); + setValue('queryTarget', ['null']); + } + return tmp; + }, [form.queryType, form.queryLocation, getDirective]); function submitHandler(): void { if (process.env.NODE_ENV === 'development') { @@ -200,7 +204,11 @@ export const LookingGlassForm = (): JSX.Element => { - + {directive !== null && (