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:
commit
af7cf95968
41 changed files with 359 additions and 92 deletions
8
.github/workflows/backend.yml
vendored
8
.github/workflows/backend.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/frontend.yml
vendored
4
.github/workflows/frontend.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
17
CHANGELOG.md
17
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
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""hyperglass API."""
|
||||
|
||||
# Standard Library
|
||||
import logging
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Helper functions for CLI message printing."""
|
||||
|
||||
# Standard Library
|
||||
import typing as t
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)})"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
6
hyperglass/external/_base.py
vendored
6
hyperglass/external/_base.py
vendored
|
|
@ -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:
|
||||
|
|
|
|||
4
hyperglass/external/bgptools.py
vendored
4
hyperglass/external/bgptools.py
vendored
|
|
@ -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 {}
|
||||
|
|
|
|||
1
hyperglass/external/tests/test_base.py
vendored
1
hyperglass/external/tests/test_base.py
vendored
|
|
@ -1,4 +1,5 @@
|
|||
"""Test external http client."""
|
||||
|
||||
# Standard Library
|
||||
import asyncio
|
||||
|
||||
|
|
|
|||
1
hyperglass/external/tests/test_bgptools.py
vendored
1
hyperglass/external/tests/test_bgptools.py
vendored
|
|
@ -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"}),
|
||||
|
|
|
|||
7
hyperglass/external/tests/test_rpki.py
vendored
7
hyperglass/external/tests/test_rpki.py
vendored
|
|
@ -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(
|
||||
assert result == expected, (
|
||||
"RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format(
|
||||
prefix, asn, result, result_name, expected, expected_name
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Query & Response Validation Models."""
|
||||
|
||||
# Local
|
||||
from .query import Query
|
||||
from .response import (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Validation model for cache config."""
|
||||
|
||||
|
||||
# Local
|
||||
from ..main import HyperglassModel
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
85
hyperglass/plugins/_builtin/bgp_route_frr.py
Normal file
85
hyperglass/plugins/_builtin/bgp_route_frr.py
Normal 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
|
||||
47
hyperglass/plugins/_builtin/bgp_route_huawei.py
Normal file
47
hyperglass/plugins/_builtin/bgp_route_huawei.py
Normal 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}"
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
"""Test BGP Community validation."""
|
||||
|
||||
# Standard Library
|
||||
import typing as t
|
||||
|
||||
|
|
|
|||
55
hyperglass/plugins/tests/test_bgp_route_frr.py
Normal file
55
hyperglass/plugins/tests/test_bgp_route_frr.py
Normal 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)
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
hyperglass/ui/pnpm-workspace.yaml
Normal file
5
hyperglass/ui/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
onlyBuiltDependencies:
|
||||
- '@biomejs/biome'
|
||||
- esbuild
|
||||
packages:
|
||||
- .
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
# features: []
|
||||
# all-features: false
|
||||
# with-sources: false
|
||||
# generate-hashes: false
|
||||
# universal: false
|
||||
|
||||
-e file:.
|
||||
aiofiles==23.2.1
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
# features: []
|
||||
# all-features: false
|
||||
# with-sources: false
|
||||
# generate-hashes: false
|
||||
# universal: false
|
||||
|
||||
-e file:.
|
||||
aiofiles==23.2.1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue