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 && (