1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 00:38:06 +00:00

Merge branch 'main' into computroniks/bug/#311-fix-field-validation

This commit is contained in:
Jason Hall 2025-09-27 20:58:59 -04:00 committed by GitHub
commit af7cf95968
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 359 additions and 92 deletions

View file

@ -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

View file

@ -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:

View file

@ -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": {

View file

@ -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

View file

@ -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/)
<Callout type="warning">Make sure the Redis server is started.</Callout>
@ -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 .
```

View file

@ -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
```

View file

@ -1,4 +1,5 @@
"""hyperglass API."""
# Standard Library
import logging

View file

@ -1,4 +1,5 @@
"""Helper functions for CLI message printing."""
# Standard Library
import typing as t

View file

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

View file

@ -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")

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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"),

View file

@ -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"),

View file

@ -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)

View file

@ -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)})"

View file

@ -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)

View file

@ -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:

View file

@ -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 {}

View file

@ -1,4 +1,5 @@
"""Test external http client."""
# Standard Library
import asyncio

View file

@ -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"}),

View file

@ -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
)
)

View file

@ -1,4 +1,5 @@
"""Query & Response Validation Models."""
# Local
from .query import Query
from .response import (

View file

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

View file

@ -1,6 +1,5 @@
"""Validation model for cache config."""
# Local
from ..main import HyperglassModel

View file

@ -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

View file

@ -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

View file

@ -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,
}
)

View file

@ -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)

View file

@ -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

View file

@ -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",
)

View file

@ -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

View file

@ -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}"

View file

@ -1,4 +1,5 @@
"""Test BGP Community validation."""
# Standard Library
import typing as t

View 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)

View file

@ -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}"

View file

@ -78,10 +78,14 @@ export const LookingGlassForm = (): JSX.Element => {
[],
);
const directive = useMemo<Directive | null>(
() => getDirective(),
[form.queryType, form.queryLocation, getDirective],
);
const directive = useMemo<Directive | null>(() => {
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 => {
<QueryType onChange={handleChange} label={web.text.queryType} />
</FormField>
</SlideFade>
<SlideFade offsetX={100} in={directive !== null} unmountOnExit>
<SlideFade
offsetX={100}
in={directive !== null && directive.fieldType !== null}
unmountOnExit
>
{directive !== null && (
<FormField name="queryTarget" label={web.text.queryTarget}>
<QueryTarget

View file

@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@biomejs/biome'
- esbuild
packages:
- .

View file

@ -107,6 +107,24 @@ exclude = [
"hyperglass/api/examples/*.py",
"hyperglass/compat/_sshtunnel.py",
]
line-length = 100
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint]
fixable=["ALL"]
unfixable=[]
select = ["B", "C", "D", "E", "F", "I", "N", "S", "RET", "W"]
ignore = [
# "W503",
"RET504",
@ -128,14 +146,6 @@ ignore = [
"B905", # zip without `strict`
"W293", # blank line contains whitespace
]
line-length = 100
select = ["B", "C", "D", "E", "F", "I", "N", "S", "RET", "W"]
[tool.ruff.pydocstyle]
convention = "pep257"
[tool.ruff.mccabe]
max-complexity = 10
[tool.ruff.lint.per-file-ignores]
"hyperglass/main.py" = ["E402"]

View file

@ -6,6 +6,8 @@
# features: []
# all-features: false
# with-sources: false
# generate-hashes: false
# universal: false
-e file:.
aiofiles==23.2.1

View file

@ -6,6 +6,8 @@
# features: []
# all-features: false
# with-sources: false
# generate-hashes: false
# universal: false
-e file:.
aiofiles==23.2.1