diff --git a/specs/tools/apply_banners.py b/specs/tools/apply_banners.py deleted file mode 100755 index 6df27ae..0000000 --- a/specs/tools/apply_banners.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -BANNER = """// GENERATED FILE - DO NOT EDIT -// Source: specs/components/*.k9.ncl -// Regenerate: specs/tools/update_manifest.sh -""" - -ROOT = Path(__file__).resolve().parents[1] -OUTPUTS = ROOT / "outputs" - -for path in sorted(OUTPUTS.glob("*.adoc")): - text = path.read_text() - if text.startswith("// GENERATED FILE - DO NOT EDIT"): - continue - path.write_text(BANNER + text) diff --git a/src/stale/hyperglass/hyperglass/__init__.py b/src/stale/hyperglass/hyperglass/__init__.py deleted file mode 100644 index 94e2018..0000000 --- a/src/stale/hyperglass/hyperglass/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -"""hyperglass is a modern, customizable network looking glass. - -https://github.com/thatmattlove/hyperglass - -The Clear BSD License - -Copyright (c) 2023 Matthew Love -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted (subject to the limitations in the disclaimer -below) provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - * Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - -NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY -THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND -CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR -BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER -IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -""" - -# Third Party -import uvloop - -# Project -from hyperglass.constants import METADATA - -# Use Uvloop for performance. -uvloop.install() - -__name__, __version__, __author__, __copyright__, __license__ = METADATA diff --git a/src/stale/hyperglass/hyperglass/api/__init__.py b/src/stale/hyperglass/hyperglass/api/__init__.py deleted file mode 100644 index 65458f9..0000000 --- a/src/stale/hyperglass/hyperglass/api/__init__.py +++ /dev/null @@ -1,72 +0,0 @@ -"""hyperglass API.""" - -# Standard Library -import logging - -# Third Party -from litestar import Litestar -from litestar.openapi import OpenAPIConfig -from litestar.exceptions import HTTPException, ValidationException -from litestar.static_files import create_static_files_router - -# Project -from hyperglass.state import use_state -from hyperglass.constants import __version__ -from hyperglass.exceptions import HyperglassError - -# Local -from .events import check_redis -from .routes import info, query, device, devices, queries -from .middleware import COMPRESSION_CONFIG, create_cors_config -from .error_handlers import app_handler, http_handler, default_handler, validation_handler - -__all__ = ("app",) - -STATE = use_state() - -UI_DIR = STATE.settings.static_path / "ui" -IMAGES_DIR = STATE.settings.static_path / "images" - - -OPEN_API = OpenAPIConfig( - title=STATE.params.docs.title.format(site_title=STATE.params.site_title), - version=__version__, - description=STATE.params.docs.description, - path=STATE.params.docs.path, - root_schema_site="elements", -) - -HANDLERS = [ - device, - devices, - queries, - info, - query, -] - -if not STATE.settings.disable_ui: - HANDLERS = [ - *HANDLERS, - create_static_files_router( - path="/images", directories=[IMAGES_DIR], name="images", include_in_schema=False - ), - create_static_files_router( - path="/", directories=[UI_DIR], name="ui", html_mode=True, include_in_schema=False - ), - ] - - -app = Litestar( - route_handlers=HANDLERS, - exception_handlers={ - HTTPException: http_handler, - HyperglassError: app_handler, - ValidationException: validation_handler, - Exception: default_handler, - }, - on_startup=[check_redis], - debug=STATE.settings.debug, - cors_config=create_cors_config(state=STATE), - compression_config=COMPRESSION_CONFIG, - openapi_config=OPEN_API if STATE.params.docs.enable else None, -) diff --git a/src/stale/hyperglass/hyperglass/api/error_handlers.py b/src/stale/hyperglass/hyperglass/api/error_handlers.py deleted file mode 100644 index e56f884..0000000 --- a/src/stale/hyperglass/hyperglass/api/error_handlers.py +++ /dev/null @@ -1,78 +0,0 @@ -"""API Error Handlers.""" - -# Standard Library -import typing as t - -# Third Party -from litestar import Request, Response -from litestar.exceptions import ValidationException - -# Project -from hyperglass.log import log -from hyperglass.state import use_state - -__all__ = ( - "default_handler", - "http_handler", - "app_handler", - "validation_handler", -) - - -def get_validation_exception_detail(exc: ValidationException) -> Response: - data: dict[str, t.Any] = { - "level": "error", - "status_code": 422, - "keywords": [], - "output": repr(exc), - } - if isinstance(exc.extra, dict): - outputs = [] - kw = [] - for k, v in exc.extra.values(): - outputs = [*outputs, f"{k}: {v!r}"] - kw = [*kw, k] - data["output"] = "\n".join(outputs) - data["keywords"] = kw - - if isinstance(exc.extra, list): - data["output"] = "\n".join(str(v) for v in exc.extra) - data["keywords"] = [] - - return Response(data) - - -def default_handler(request: Request, exc: BaseException) -> Response: - """Handle uncaught errors.""" - state = use_state() - log.bind(method=request.method, path=request.url.path, detail=str(exc)).critical("Error") - return Response( - {"output": state.params.messages.general, "level": "danger", "keywords": []}, - status_code=500, - ) - - -def http_handler(request: Request, exc: BaseException) -> Response: - """Handle web server errors.""" - log.bind(method=request.method, path=request.url.path, detail=exc.detail).critical("HTTP Error") - return Response( - {"output": exc.detail, "level": "danger", "keywords": []}, - status_code=exc.status_code, - ) - - -def app_handler(request: Request, exc: BaseException) -> Response: - """Handle application errors.""" - log.bind(method=request.method, path=request.url.path, detail=exc.message).critical( - "hyperglass Error" - ) - return Response( - {"output": exc.message, "level": exc.level, "keywords": exc.keywords}, - status_code=exc.status_code, - ) - - -def validation_handler(request: Request, exc: ValidationException) -> Response: - """Handle Pydantic validation errors raised by FastAPI.""" - log.bind(method=request.method, path=request.url.path, detail=exc).critical("Validation Error") - return get_validation_exception_detail(exc) diff --git a/src/stale/hyperglass/hyperglass/api/events.py b/src/stale/hyperglass/hyperglass/api/events.py deleted file mode 100644 index 3d20c4a..0000000 --- a/src/stale/hyperglass/hyperglass/api/events.py +++ /dev/null @@ -1,18 +0,0 @@ -"""API Events.""" - -# Standard Library -import typing as t - -# Third Party -from litestar import Litestar - -# Project -from hyperglass.state import use_state - -__all__ = ("check_redis",) - - -async def check_redis(_: Litestar) -> t.NoReturn: - """Ensure Redis is running before starting server.""" - cache = use_state("cache") - cache.check() diff --git a/src/stale/hyperglass/hyperglass/api/fake_output.py b/src/stale/hyperglass/hyperglass/api/fake_output.py deleted file mode 100644 index 8405a82..0000000 --- a/src/stale/hyperglass/hyperglass/api/fake_output.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Return fake, static data for development purposes.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.models.data import BGPRouteTable - -BGP_PLAIN = r"""BGP routing table entry for 4.0.0.0/9, version 1017877672 -BGP Bestpath: deterministic-med -Paths: (10 available, best #9, table default) - Advertised to update-groups: - 50 - 1299 3356, (aggregated by 3356 4.69.130.24) - 216.250.230.1 (metric 2000) from 216.250.230.1 (216.250.230.1) - Origin IGP, metric 0, localpref 100, weight 100, valid, internal, atomic-aggregate - Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3003 14525:4002 14525:9003 - 1299 3356, (aggregated by 3356 4.69.130.24), (received-only) - 216.250.230.1 (metric 2000) from 216.250.230.1 (216.250.230.1) - Origin IGP, metric 0, localpref 150, valid, internal, atomic-aggregate - Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3003 14525:4002 14525:9003 - 1299 3356, (aggregated by 3356 4.69.130.184) - 199.34.92.9 (metric 1000) from 199.34.92.9 (199.34.92.9) - Origin IGP, metric 0, localpref 100, weight 100, valid, internal, atomic-aggregate - Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9003 - 1299 3356, (aggregated by 3356 4.69.130.184), (received-only) - 199.34.92.9 (metric 1000) from 199.34.92.9 (199.34.92.9) - Origin IGP, metric 0, localpref 150, valid, internal, atomic-aggregate - Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9003 - 174 3356, (aggregated by 3356 4.69.130.4) - 199.34.92.10 (metric 1000) from 199.34.92.10 (199.34.92.10) - Origin IGP, metric 0, localpref 100, weight 100, valid, internal, atomic-aggregate - Community: 174:21000 174:22013 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9001 - 174 3356, (aggregated by 3356 4.69.130.4), (received-only) - 199.34.92.10 (metric 1000) from 199.34.92.10 (199.34.92.10) - Origin IGP, metric 0, localpref 150, valid, internal, atomic-aggregate - Community: 174:21000 174:22013 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9001 - 209 3356, (aggregated by 3356 4.69.130.2) - 199.34.92.5 (metric 101) from 199.34.92.5 (199.34.92.5) - Origin IGP, metric 8006570, localpref 150, weight 200, valid, internal, atomic-aggregate - Community: 209:88 209:888 3356:0 3356:3 3356:100 3356:123 3356:575 3356:2011 14525:0 14525:40 14525:1021 14525:2840 14525:3002 14525:4003 14525:9005 - 209 3356, (aggregated by 3356 4.69.130.2), (received-only) - 199.34.92.5 (metric 101) from 199.34.92.5 (199.34.92.5) - Origin IGP, metric 8006570, localpref 150, valid, internal, atomic-aggregate - Community: 209:88 209:888 3356:0 3356:3 3356:100 3356:123 3356:575 3356:2011 14525:0 14525:40 14525:1021 14525:2840 14525:3002 14525:4003 14525:9005 - 6939 3356, (aggregated by 3356 4.69.130.4) - 184.105.247.177 from 184.105.247.177 (216.218.252.234) - Origin IGP, localpref 150, weight 200, valid, external, atomic-aggregate, best - Community: 6939:7016 6939:8840 6939:9001 14525:0 14525:40 14525:1021 14525:2840 14525:3002 14525:4003 14525:9002 - 6939 3356, (aggregated by 3356 4.69.130.4), (received-only) - 184.105.247.177 from 184.105.247.177 (216.218.252.234) - Origin IGP, localpref 100, valid, external, atomic-aggregate - Community: 6939:7016 6939:8840 6939:9001 -""" # noqa: W291,E501 - -BGP_ROUTES = [ - { - "prefix": "1.1.1.0/24", - "active": True, - "age": 1025337, - "weight": 170, - "med": 0, - "local_preference": 175, - "as_path": [1299, 13335], - "communities": [ - "1299:35000", - "14525:0", - "14525:41", - "14525:600", - "14525:1021", - "14525:2840", - "14525:3001", - "14525:4001", - "14525:9003", - ], - "next_hop": "62.115.189.136", - "source_as": 13335, - "source_rid": "141.101.72.1", - "peer_rid": "2.255.254.43", - "rpki_state": 1, - }, - { - "prefix": "1.1.1.0/24", - "active": False, - "age": 1584622, - "weight": 200, - "med": 0, - "local_preference": 250, - "as_path": [13335], - "communities": [ - "14525:0", - "14525:20", - "14525:600", - "14525:1021", - "14525:2840", - "14525:3002", - "14525:4003", - "14525:9009", - ], - "next_hop": "", - "source_as": 13335, - "source_rid": "172.68.129.1", - "peer_rid": "199.34.92.5", - "rpki_state": 3, - }, - { - "prefix": "1.1.1.0/24", - "active": False, - "age": 982517, - "weight": 200, - "med": 0, - "local_preference": 250, - "as_path": [13335], - "communities": [ - "14525:0", - "14525:20", - "14525:600", - "14525:1021", - "14525:2840", - "14525:3002", - "14525:4003", - "14525:9009", - ], - "next_hop": "", - "source_as": 13335, - "source_rid": "172.68.129.1", - "peer_rid": "199.34.92.6", - "rpki_state": 3, - }, - { - "prefix": "1.1.1.0/24", - "active": False, - "age": 1000101, - "weight": 200, - "med": 0, - "local_preference": 250, - "as_path": [13335], - "communities": [ - "13335:10014", - "13335:19000", - "13335:20050", - "13335:20500", - "13335:20530", - "14525:0", - "14525:20", - "14525:600", - "14525:1021", - "14525:2840", - "14525:3003", - "14525:4002", - "14525:9009", - ], - "next_hop": "", - "source_as": 13335, - "source_rid": "141.101.73.1", - "peer_rid": "216.250.230.2", - "rpki_state": 3, - }, -] - -PING = r"""PING 1.1.1.1 (1.1.1.1): 56 data bytes -64 bytes from 1.1.1.1: icmp_seq=0 ttl=59 time=4.696 ms -64 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=4.699 ms -64 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=4.640 ms -64 bytes from 1.1.1.1: icmp_seq=3 ttl=59 time=4.583 ms -64 bytes from 1.1.1.1: icmp_seq=4 ttl=59 time=4.640 ms - ---- 1.1.1.1 ping statistics --- -5 packets transmitted, 5 packets received, 0% packet loss -round-trip min/avg/max/stddev = 4.583/4.652/4.699/0.043 ms -""" - -TRACEROUTE = r"""traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 52 byte packets - 1 157.231.183.50 4.412 ms - 2 129.219.10.4 4.612 ms - 3 128.249.9.12 4.503 ms - 4 139.15.19.3 7.458 ms - 5 172.69.68.3 4.814 ms - 6 1.1.1.1 4.564 ms -""" - - -async def fake_output(query_type: str, structured: bool) -> t.Union[str, BGPRouteTable]: - """Bypass the standard execution process and return static, fake output.""" - - if "ping" in query_type: - return PING - if "traceroute" in query_type: - return TRACEROUTE - if "bgp" in query_type: - if structured: - return BGPRouteTable( - vrf="default", - count=len(BGP_ROUTES), - routes=BGP_ROUTES, - winning_weight="high", - ) - return BGP_PLAIN - return BGP_PLAIN diff --git a/src/stale/hyperglass/hyperglass/api/middleware.py b/src/stale/hyperglass/hyperglass/api/middleware.py deleted file mode 100644 index 71a5726..0000000 --- a/src/stale/hyperglass/hyperglass/api/middleware.py +++ /dev/null @@ -1,34 +0,0 @@ -"""hyperglass API middleware.""" - -# Standard Library -import typing as t - -# Third Party -from litestar.config.cors import CORSConfig -from litestar.config.compression import CompressionConfig - -if t.TYPE_CHECKING: - # Project - from hyperglass.state import HyperglassState - -__all__ = ("create_cors_config", "COMPRESSION_CONFIG") - -COMPRESSION_CONFIG = CompressionConfig(backend="brotli", brotli_gzip_fallback=True) - -REQUEST_LOG_MESSAGE = "REQ" -RESPONSE_LOG_MESSAGE = "RES" -REQUEST_LOG_FIELDS = ("method", "path", "path_params", "query") -RESPONSE_LOG_FIELDS = ("status_code",) - - -def create_cors_config(state: "HyperglassState") -> CORSConfig: - """Create CORS configuration from parameters.""" - origins = state.params.cors_origins.copy() - if state.settings.dev_mode: - origins = [*origins, state.settings.dev_url, "http://localhost:3000"] - - return CORSConfig( - allow_origins=origins, - allow_methods=["GET", "POST", "OPTIONS"], - allow_headers=["*"], - ) diff --git a/src/stale/hyperglass/hyperglass/api/routes.py b/src/stale/hyperglass/hyperglass/api/routes.py deleted file mode 100644 index 6a5e6ed..0000000 --- a/src/stale/hyperglass/hyperglass/api/routes.py +++ /dev/null @@ -1,129 +0,0 @@ -"""API Routes for the hyperglass Network Looking Glass. - -This module implements the Litestar-based REST API, providing endpoints -for device discovery, query execution, and system information. -""" - -# Standard Library -import json -import time -import typing as t -from datetime import UTC, datetime - -# Third Party -from litestar import Request, Response, get, post -from litestar.di import Provide -from litestar.background_tasks import BackgroundTask - -# Project -from hyperglass.log import log -from hyperglass.state import HyperglassState -from hyperglass.exceptions import HyperglassError -from hyperglass.models.api import Query -from hyperglass.models.data import OutputDataModel -from hyperglass.util.typing import is_type -from hyperglass.execution.main import execute -from hyperglass.models.api.response import QueryResponse -from hyperglass.models.config.params import Params, APIParams -from hyperglass.models.config.devices import Devices, APIDevice - -# Local -from .state import get_state, get_params, get_devices -from .tasks import send_webhook -from .fake_output import fake_output - -__all__ = ( - "device", - "devices", - "queries", - "info", - "query", -) - - -@get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)}) -async def device(devices: Devices, id: str) -> APIDevice: - """Retrieve metadata for a specific network device by its unique ID.""" - return devices[id].export_api() - - -@get("/api/devices", dependencies={"devices": Provide(get_devices)}) -async def devices(devices: Devices) -> t.List[APIDevice]: - """Retrieve the complete list of available network looking glass locations.""" - return devices.export_api() - - -@get("/api/queries", dependencies={"devices": Provide(get_devices)}) -async def queries(devices: Devices) -> t.List[str]: - """Retrieve all globally available query types (e.g., bgp_route, ping).""" - return devices.directive_names() - - -@post("/api/query", dependencies={"_state": Provide(get_state)}) -async def query(_state: HyperglassState, request: Request, data: Query) -> QueryResponse: - """EXECUTION ENGINE: Ingests a validated query and performs the network look-up. - - LIFECYCLE: - 1. Check Redis for a cached response using the query's SHA256 digest. - 2. CACHE HIT: Return cached data and reset expiration timer. - 3. CACHE MISS: Execute the query via the driver (SSH or HTTP). - 4. PERSIST: Store the new response in Redis. - 5. LOG: Trigger an asynchronous webhook task for auditing. - """ - - timestamp = datetime.now(UTC) - cache = _state.redis - cache_key = f"hyperglass.query.{data.digest()}" - - _log = log.bind(query=data.summary()) - _log.info("Processing request") - - # ATTEMPT CACHE LOOKUP - cache_response = cache.get_map(cache_key, "output") - cached = False - runtime = 0 - - if cache_response: - _log.bind(cache_key=cache_key).debug("Cache hit") - cache.expire(cache_key, expire_in=_state.params.cache.timeout) - cached = True - timestamp = cache.get_map(cache_key, "timestamp") - else: - _log.bind(cache_key=cache_key).debug("Cache miss") - starttime = time.time() - - # EXECUTION: Perform the real or fake network query - if _state.params.fake_output: - output = await fake_output(query_type=data.query_type, structured=data.device.structured_output) - else: - output = await execute(data) - - elapsedtime = round(time.time() - starttime, 4) - runtime = int(round(elapsedtime, 0)) - - # SERIALIZATION: Coerce output to JSON if it's a structured model - raw_output = output.export_dict() if is_type(output, OutputDataModel) else str(output) - - # PERSISTENCE - cache.set_map_item(cache_key, "output", raw_output) - cache.set_map_item(cache_key, "timestamp", data.timestamp) - cache.expire(cache_key, expire_in=_state.params.cache.timeout) - - # FINAL RESPONSE ASSEMBLY - response_body = { - "output": cache.get_map(cache_key, "output"), - "id": cache_key, - "cached": cached, - "runtime": runtime, - "timestamp": timestamp, - "format": "application/json" if is_type(cache_response, dict) else "text/plain", - "random": data.random(), - "level": "success", - } - - return Response( - response_body, - background=BackgroundTask( - send_webhook, params=_state.params, data=data, request=request, timestamp=timestamp - ), - ) diff --git a/src/stale/hyperglass/hyperglass/api/state.py b/src/stale/hyperglass/hyperglass/api/state.py deleted file mode 100644 index f41a96d..0000000 --- a/src/stale/hyperglass/hyperglass/api/state.py +++ /dev/null @@ -1,27 +0,0 @@ -"""hyperglass state dependencies.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.state import use_state - - -async def get_state(attr: t.Optional[str] = None): - """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) - - -async def get_params(): - """Get hyperglass params as FastAPI dependency.""" - return use_state("params") - - -async def get_devices(): - """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") - - -async def get_ui_params(): - """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") diff --git a/src/stale/hyperglass/hyperglass/api/tasks.py b/src/stale/hyperglass/hyperglass/api/tasks.py deleted file mode 100644 index 0a7d16d..0000000 --- a/src/stale/hyperglass/hyperglass/api/tasks.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tasks to be executed from web API.""" - -# Standard Library -import typing as t -from datetime import datetime - -# Third Party -from httpx import Headers -from litestar import Request - -# Project -from hyperglass.log import log -from hyperglass.external import Webhook, bgptools -from hyperglass.models.api import Query - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.config.params import Params - -__all__ = ("send_webhook",) - - -async def process_headers(headers: Headers) -> t.Dict[str, t.Any]: - """Filter out unwanted headers and return as a dictionary.""" - headers = dict(headers) - header_keys = ( - "user-agent", - "referer", - "accept-encoding", - "accept-language", - "x-real-ip", - "x-forwarded-for", - ) - return {k: headers.get(k) for k in header_keys} - - -async def send_webhook( - params: "Params", - data: Query, - request: Request, - timestamp: datetime, -) -> t.NoReturn: - """If webhooks are enabled, get request info and send a webhook.""" - try: - if params.logging.http is not None: - headers = await process_headers(headers=request.headers) - - if headers.get("x-real-ip") is not None: - host = headers["x-real-ip"] - elif headers.get("x-forwarded-for") is not None: - host = headers["x-forwarded-for"] - else: - host = request.client.host - - network_info = await bgptools.network_info(host) - - async with Webhook(params.logging.http) as hook: - await hook.send( - query={ - **data.dict(), - "headers": headers, - "source": host, - "network": network_info.get(host, {}), - "timestamp": timestamp, - } - ) - except Exception as err: - log.bind(destination=params.logging.http.provider, error=str(err)).error( - "Failed to send webhook" - ) diff --git a/src/stale/hyperglass/hyperglass/cli/__init__.py b/src/stale/hyperglass/hyperglass/cli/__init__.py deleted file mode 100644 index be23627..0000000 --- a/src/stale/hyperglass/hyperglass/cli/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""hyperglass cli module.""" - -# Local -from .main import cli, run - -__all__ = ("cli", "run") diff --git a/src/stale/hyperglass/hyperglass/cli/echo.py b/src/stale/hyperglass/hyperglass/cli/echo.py deleted file mode 100644 index 4f7ced0..0000000 --- a/src/stale/hyperglass/hyperglass/cli/echo.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Helper functions for CLI message printing.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.log import HyperglassConsole - - -class Echo: - """Container for console-printing functions.""" - - _console = HyperglassConsole - - def _fmt(self, message: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: - if isinstance(message, str): - args = (f"[bold]{arg}[/bold]" for arg in args) - kwargs = {k: f"[bold]{v}[/bold]" for k, v in kwargs.items()} - return message.format(*args, **kwargs) - return message - - def error(self, message: str, *args, **kwargs): - """Print an error message.""" - return self._console.print(self._fmt(message, *args, **kwargs), style="error") - - def info(self, message: str, *args, **kwargs): - """Print an informational message.""" - return self._console.print(self._fmt(message, *args, **kwargs), style="info") - - def warning(self, message: str, *args, **kwargs): - """Print a warning message.""" - return self._console.print(self._fmt(message, *args, **kwargs), style="info") - - def success(self, message: str, *args, **kwargs): - """Print a success message.""" - return self._console.print(self._fmt(message, *args, **kwargs), style="success") - - def plain(self, message: str, *args, **kwargs): - """Print an unformatted message.""" - return self._console.print(self._fmt(message, *args, **kwargs)) - - -echo = Echo() diff --git a/src/stale/hyperglass/hyperglass/cli/installer.py b/src/stale/hyperglass/hyperglass/cli/installer.py deleted file mode 100644 index eeb760d..0000000 --- a/src/stale/hyperglass/hyperglass/cli/installer.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Install hyperglass.""" - -# Standard Library -import os -import time -import shutil -import typing as t -import getpass -from types import TracebackType -from filecmp import dircmp -from pathlib import Path - -# Third Party -import typer -from rich.progress import Progress - -# Project -from hyperglass.util import compare_lists -from hyperglass.settings import Settings -from hyperglass.constants import __version__ - -# Local -from .echo import echo - -ASSET_DIR = Path(__file__).parent.parent / "images" -IGNORED_FILES = [".DS_Store"] - - -class Installer: - """Install hyperglass.""" - - app_path: Path - progress: Progress - user: str - assets: int - - def __init__(self): - """Start hyperglass installer.""" - self.app_path = Settings.app_path - self.progress: Progress = Progress(console=echo._console) - self.user = getpass.getuser() - self.assets = len([p for p in ASSET_DIR.iterdir() if p.name not in IGNORED_FILES]) - - def install(self) -> None: - """Initialize tasks and start installer.""" - permissions_task = self.progress.add_task("[bright purple]Checking System", total=2) - scaffold_task = self.progress.add_task( - "[bright blue]Creating Directory Structures", total=3 - ) - asset_task = self.progress.add_task( - "[bright cyan]Migrating Static Assets", total=self.assets - ) - ui_task = self.progress.add_task("[bright teal]Initialzing UI", total=1, start=False) - - self.progress.start() - - self.check_permissions(task_id=permissions_task) - self.scaffold(task_id=scaffold_task) - self.migrate_static_assets(task_id=asset_task) - self.init_ui(task_id=ui_task) - - def __enter__(self) -> t.Callable[[], None]: - """Initialize tasks.""" - self.progress.print(f"Starting hyperglass {__version__} setup") - return self.install - - def __exit__( - self, - exc_type: t.Optional[t.Type[BaseException]] = None, - exc_value: t.Optional[BaseException] = None, - exc_traceback: t.Optional[TracebackType] = None, - ): - """Print errors on exit.""" - self.progress.stop() - if exc_type is not None: - echo._console.print_exception(show_locals=True) - raise typer.Exit(1) - raise typer.Exit(0) - - def check_permissions(self, task_id: int) -> None: - """Ensure the executing user has permissions to the app path.""" - read = os.access(self.app_path, os.R_OK) - if not read: - self.progress.print( - f"User {self.user!r} does not have read access to {self.app_path!s}", style="error" - ) - raise typer.Exit(1) - - self.progress.advance(task_id) - time.sleep(0.4) - - write = os.access(self.app_path, os.W_OK) - if not write: - self.progress.print( - f"User {self.user!r} does not have write access to {self.app_path!s}", style="error" - ) - raise typer.Exit(1) - self.progress.advance(task_id) - - def scaffold(self, task_id: int) -> None: - """Create the file structure necessary for hyperglass to run.""" - - if not self.app_path.exists(): - self.progress.print("Created {!s}".format(self.app_path), style="info") - self.app_path.mkdir(parents=True) - - self.progress.print(f"hyperglass path is {self.app_path!s}", style="subtle") - self.progress.advance(task_id) - - ui_dir = self.app_path / "static" / "ui" - favicon_dir = self.app_path / "static" / "images" / "favicons" - - for path in (ui_dir, favicon_dir): - if not path.exists(): - self.progress.print("Created {!s}".format(path), style="info") - path.mkdir(parents=True) - - self.progress.advance(task_id) - time.sleep(0.4) - - def migrate_static_assets(self, task_id: int) -> None: - """Synchronize the project assets with the installation assets.""" - - target_dir = self.app_path / "static" / "images" - - def copy_func(src: str, dst: str): - time.sleep(self.assets / 10) - - exists = Path(dst).exists() - if not exists: - copied = shutil.copy2(src, dst) - self.progress.print(f"Copied {copied!s}", style="info") - self.progress.advance(task_id) - return dst - - if not target_dir.exists(): - shutil.copytree( - ASSET_DIR, - target_dir, - ignore=shutil.ignore_patterns(*IGNORED_FILES), - copy_function=copy_func, - ) - - # Compare the contents of the project's asset directory (considered - # the source of truth) with the installation directory. If they do - # not match, delete the installation directory's asset directory and - # re-copy it. - compare_initial = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) - - if not compare_lists( - compare_initial.left_list, - compare_initial.right_list, - ignore=["hyperglass-opengraph.jpg"], - ): - shutil.rmtree(target_dir) - shutil.copytree( - ASSET_DIR, - target_dir, - copy_function=copy_func, - ignore=shutil.ignore_patterns(*IGNORED_FILES), - ) - - # Re-compare the source and destination directory contents to - # ensure they match. - compare_post = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) - - if not compare_lists( - compare_post.left_list, compare_post.right_list, ignore=["hyperglass-opengraph.jpg"] - ): - echo.error("Files in {!s} do not match files in {!s}", ASSET_DIR, target_dir) - raise typer.Exit(1) - else: - self.progress.update(task_id, completed=self.assets, refresh=True) - - def init_ui(self, task_id: int) -> None: - """Initialize UI.""" - # Project - from hyperglass.log import log - - # Local - from .util import build_ui - - with self.progress.console.capture(): - log.disable("hyperglass") - build_ui(timeout=180) - log.enable("hyperglass") - self.progress.advance(task_id) diff --git a/src/stale/hyperglass/hyperglass/cli/main.py b/src/stale/hyperglass/hyperglass/cli/main.py deleted file mode 100644 index bca4a8f..0000000 --- a/src/stale/hyperglass/hyperglass/cli/main.py +++ /dev/null @@ -1,335 +0,0 @@ -"""hyperglass Command Line Interface.""" - -# Standard Library -import re -import sys -import typing as t - -# Third Party -import typer - -# Local -from .echo import echo - - -cli = typer.Typer(name="hyperglass", help="hyperglass Command Line Interface", no_args_is_help=True) - -@cli.callback() # This is the main callback for the Typer app itself -def main_callback() -> None: - """Manage hyperglass application.""" - # This main_callback function is run for every command. - pass - -def run(): - """Run the hyperglass CLI.""" - # This will now correctly execute the cli. - return typer.run(cli()) - - -@cli.command(name="start") -def _start(build: bool = False, workers: t.Optional[int] = None) -> None: - """Start hyperglass""" - # Project - from hyperglass.main import run - - # Local - from .util import build_ui - - kwargs = {} - if workers != 0: - kwargs["workers"] = workers - - try: - if build: - build_complete = build_ui(timeout=180) - if build_complete: - run(workers) - else: - run(workers) - - except (KeyboardInterrupt, SystemExit) as err: - error_message = str(err) - if (len(error_message)) > 1: - echo.warning(str(err)) - echo.error("Stopping hyperglass due to keyboard interrupt.") - raise typer.Exit(0) - - -@cli.command(name="build-ui") -def _build_ui(timeout: int = typer.Option(180, help="Timeout in seconds")) -> None: - """Create a new UI build.""" - # Local - from .util import build_ui as _build_ui - - with echo._console.status( - f"Starting new UI build with a {timeout} second timeout...", spinner="aesthetic" - ): - _build_ui(timeout=120) - - -@cli.command(name="system-info") -def _system_info(): - """Get system information for a bug report""" - # Third Party - from rich import box - from rich.panel import Panel - from rich.table import Table - - # Project - from hyperglass.util.system_info import get_system_info - - # Local - from .static import MD_BOX - - data = get_system_info() - - rows = tuple( - (f"**{title}**", f"`{value!s}`" if mod == "code" else str(value)) - for title, (value, mod) in data.items() - ) - - table = Table("Metric", "Value", box=MD_BOX) - for title, metric in rows: - table.add_row(title, metric) - echo._console.print( - Panel( - "Please copy & paste this table in your bug report", - style="bold yellow", - expand=False, - border_style="yellow", - box=box.HEAVY, - ) - ) - echo.plain(table) - - -@cli.command(name="clear-cache") -def _clear_cache(): - """Clear the Redis cache""" - # Project - from hyperglass.state import use_state - - state = use_state() - - try: - state.clear() - echo.success("Cleared Redis Cache") - - except Exception as err: - if not sys.stdout.isatty(): - echo._console.print_exception(show_locals=True) - raise typer.Exit(1) - - echo.error("Error clearing cache: {!s}", err) - raise typer.Exit(1) - - -@cli.command(name="devices") -def _devices( - search: t.Optional[str] = typer.Argument(None, help="Device ID or Name Search Pattern"), -): - """Show all configured devices""" - # Third Party - from rich.columns import Columns - from rich._inspect import Inspect - - # Project - from hyperglass.state import use_state - - devices = use_state("devices") - if search is not None: - pattern = re.compile(search, re.IGNORECASE) - for device in devices: - if pattern.match(device.id) or pattern.match(device.name): - echo._console.print( - Inspect( - device, - title=device.name, - docs=False, - methods=False, - dunder=False, - sort=True, - all=False, - value=True, - help=False, - ) - ) - raise typer.Exit(0) - - panels = [ - Inspect( - device, - title=device.name, - docs=False, - methods=False, - dunder=False, - sort=True, - all=False, - value=True, - help=False, - ) - for device in devices - ] - echo._console.print(Columns(panels)) - - -@cli.command(name="directives") -def _directives( - search: t.Optional[str] = typer.Argument(None, help="Directive ID or Name Search Pattern"), -): - """Show all configured devices""" - # Third Party - from rich.columns import Columns - from rich._inspect import Inspect - - # Project - from hyperglass.state import use_state - - directives = use_state("directives") - if search is not None: - pattern = re.compile(search, re.IGNORECASE) - for directive in directives: - if pattern.match(directive.id) or pattern.match(directive.name): - echo._console.print( - Inspect( - directive, - title=directive.name, - docs=False, - methods=False, - dunder=False, - sort=True, - all=False, - value=True, - help=False, - ) - ) - raise typer.Exit(0) - - panels = [ - Inspect( - directive, - title=directive.name, - docs=False, - methods=False, - dunder=False, - sort=True, - all=False, - value=True, - help=False, - ) - for directive in directives - ] - echo._console.print(Columns(panels)) - - -@cli.command(name="plugins") -def _plugins( - search: t.Optional[str] = typer.Argument(None, help="Plugin ID or Name Search Pattern"), - _input: bool = typer.Option( - False, "--input", show_default=False, is_flag=True, help="Show Input Plugins" - ), - output: bool = typer.Option( - False, "--output", show_default=False, is_flag=True, help="Show Output Plugins" - ), -): - """Show all configured devices""" - # Third Party - from rich.columns import Columns - - # Project - from hyperglass.state import use_state - - to_fetch = ("input", "output") - if _input is True: - to_fetch = ("input",) - - elif output is True: - to_fetch = ("output",) - - state = use_state() - all_plugins = [plugin for _type in to_fetch for plugin in state.plugins(_type)] - - if search is not None: - pattern = re.compile(search, re.IGNORECASE) - matching = [plugin for plugin in all_plugins if pattern.match(plugin.name)] - if len(matching) == 0: - echo.error(f"No plugins matching {search!r}") - raise typer.Exit(1) - - echo._console.print(Columns(matching)) - raise typer.Exit(0) - - echo._console.print(Columns(all_plugins)) - - -@cli.command(name="params") -def _params( - path: t.Optional[str] = typer.Argument( - None, help="Parameter Object Path, for example 'messages.no_input'" - ), -): - """Show configuration parameters""" - # Standard Library - from operator import attrgetter - - # Third Party - from rich._inspect import Inspect - - # Project - from hyperglass.state import use_state - - params = use_state("params") - if path is not None: - try: - value = attrgetter(path)(params) - - echo._console.print( - Inspect( - value, - title=f"params.{path}", - docs=False, - methods=False, - dunder=False, - sort=True, - all=False, - value=True, - help=False, - ) - ) - raise typer.Exit(0) - except AttributeError: - echo.error(f"{'params.' + path!r} does not exist") - raise typer.Exit(1) - - panel = Inspect( - params, - title="hyperglass Configuration Parameters", - docs=False, - methods=False, - dunder=False, - sort=True, - all=False, - value=True, - help=False, - ) - echo._console.print(panel) - - -@cli.command(name="setup") -def _setup(): - """Initialize hyperglass setup.""" - # Local - from .installer import Installer - - with Installer() as start: - start() - - -@cli.command(name="settings") -def _settings(): - """Show hyperglass system settings (environment variables)""" - - # Project - from hyperglass.settings import Settings - - echo.plain(Settings) diff --git a/src/stale/hyperglass/hyperglass/cli/static.py b/src/stale/hyperglass/hyperglass/cli/static.py deleted file mode 100644 index d6b9c82..0000000 --- a/src/stale/hyperglass/hyperglass/cli/static.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Static string definitions.""" - -# Third Party -from rich.box import Box - -MD_BOX = Box( - """\ - -| || -|-|| -| || -| | -| | -| || - -""", - ascii=True, -) - - -class Char: - """Helper class for single-character strings.""" - - def __init__(self, char): - """Set instance character.""" - self.char = char - - def __getitem__(self, i): - """Subscription returns the instance's character * n.""" - return self.char * i - - def __str__(self): - """Stringify the instance character.""" - return str(self.char) - - def __repr__(self): - """Stringify the instance character for representation.""" - return str(self.char) - - def __add__(self, other): - """Addition method for string concatenation.""" - return str(self.char) + str(other) - - -WS = Char(" ") -NL = Char("\n") -CL = Char(":") diff --git a/src/stale/hyperglass/hyperglass/cli/util.py b/src/stale/hyperglass/hyperglass/cli/util.py deleted file mode 100644 index 4dbb6cf..0000000 --- a/src/stale/hyperglass/hyperglass/cli/util.py +++ /dev/null @@ -1,51 +0,0 @@ -"""CLI utility functions.""" - -# Standard Library -import sys -import asyncio - -# Third Party -import typer - -# Local -from .echo import echo - - -def build_ui(timeout: int) -> None: - """Create a new UI build.""" - # Project - from hyperglass.state import use_state - from hyperglass.frontend import build_frontend - from hyperglass.configuration import init_user_config - - # Populate configuration to Redis prior to accessing it. - init_user_config() - - state = use_state() - - dev_mode = "production" - if state.settings.dev_mode: - dev_mode = "development" - - try: - build_success = asyncio.run( - build_frontend( - app_path=state.settings.app_path, - dev_mode=state.settings.dev_mode, - dev_url=f"http://localhost:{state.settings.port!s}/", - force=True, - params=state.ui_params, - prod_url="/api/", - timeout=timeout, - ) - ) - if build_success: - echo.success("Completed UI build in {} mode", dev_mode) - - except Exception as e: - if not sys.stdout.isatty(): - echo._console.print_exception(show_locals=True) - raise typer.Exit(1) - - echo.error("Error building UI: {!s}", e) - raise typer.Exit(1) diff --git a/src/stale/hyperglass/hyperglass/compat/__init__.py b/src/stale/hyperglass/hyperglass/compat/__init__.py deleted file mode 100644 index e498f61..0000000 --- a/src/stale/hyperglass/hyperglass/compat/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Functions for maintaining compatibility with older Python versions or libraries.""" - -# Local -from ._sshtunnel import SSHTunnelForwarder, BaseSSHTunnelForwarderError, open_tunnel - -__all__ = ( - "BaseSSHTunnelForwarderError", - "open_tunnel", - "SSHTunnelForwarder", -) diff --git a/src/stale/hyperglass/hyperglass/compat/_sshtunnel.py b/src/stale/hyperglass/hyperglass/compat/_sshtunnel.py deleted file mode 100644 index ca6547f..0000000 --- a/src/stale/hyperglass/hyperglass/compat/_sshtunnel.py +++ /dev/null @@ -1,1551 +0,0 @@ -"""Initiate SSH tunnels via a remote gateway. - -Copyright (c) 2014-2019 Pahaz Blinov - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -*sshtunnel* - Initiate SSH tunnels via a remote gateway. - -``sshtunnel`` works by opening a port forwarding SSH connection in the -background, using threads. - -The connection(s) are closed when explicitly calling the -:meth:`SSHTunnelForwarder.stop` method or using it as a context. -""" - -# Standard Library -import os -import sys -import queue -import socket -import getpass -import argparse -import warnings -import threading -import socketserver -from select import select -from binascii import hexlify - -# Third Party -import paramiko - -# Project -from hyperglass.log import log - -TUNNEL_TIMEOUT = 1.0 #: Timeout (seconds) for tunnel connection -_DAEMON = False #: Use daemon threads in connections -_CONNECTION_COUNTER = 1 -_LOCK = threading.Lock() - -DEPRECATIONS = { - "ssh_address": "ssh_address_or_host", - "ssh_host": "ssh_address_or_host", - "ssh_private_key": "ssh_pkey", - "raise_exception_if_any_forwarder_have_a_problem": "mute_exceptions", -} - -if os.name == "posix": - DEFAULT_SSH_DIRECTORY = "~/.ssh" - UnixStreamServer = socketserver.UnixStreamServer -else: - DEFAULT_SSH_DIRECTORY = "~/ssh" - UnixStreamServer = socketserver.TCPServer - -#: Path of optional ssh configuration file -SSH_CONFIG_FILE = os.path.join(DEFAULT_SSH_DIRECTORY, "config") - -######################## -# # -# Utils # -# # -######################## - - -def check_host(host): - assert isinstance(host, str), "IP is not a string ({0})".format(type(host).__name__) - - -def check_port(port): - assert isinstance(port, int), "PORT is not a number" - assert port >= 0, "PORT < 0 ({0})".format(port) - - -def check_address(address): - """Check if the format of the address is correct. - - Arguments: - address (tuple): - (``str``, ``int``) representing an IP address and port, - respectively - - .. note:: - alternatively a local ``address`` can be a ``str`` when working - with UNIX domain sockets, if supported by the platform - Raises: - ValueError: - raised when address has an incorrect format - - Example: - >>> check_address(('127.0.0.1', 22)) - """ - if isinstance(address, tuple): - check_host(address[0]) - check_port(address[1]) - elif isinstance(address, str): - if os.name != "posix": - raise ValueError("Platform does not support UNIX domain sockets") - if not (os.path.exists(address) or os.access(os.path.dirname(address), os.W_OK)): - raise ValueError("ADDRESS not a valid socket domain socket ({0})".format(address)) - else: - raise ValueError( - "ADDRESS is not a tuple, string, or character buffer " - "({0})".format(type(address).__name__) - ) - - -def check_addresses(address_list, is_remote=False): - """ - Check if the format of the addresses is correct - - Arguments: - address_list (list[tuple]): - Sequence of (``str``, ``int``) pairs, each representing an IP - address and port respectively - - .. note:: - when supported by the platform, one or more of the elements in - the list can be of type ``str``, representing a valid UNIX - domain socket - - is_remote (boolean): - Whether or not the address list - Raises: - AssertionError: - raised when ``address_list`` contains an invalid element - ValueError: - raised when any address in the list has an incorrect format - - Example: - - >>> check_addresses([('127.0.0.1', 22), ('127.0.0.1', 2222)]) - """ - assert all(isinstance(x, (tuple, str)) for x in address_list) - if is_remote and any(isinstance(x, str) for x in address_list): - raise AssertionError("UNIX domain sockets not allowed for remote" "addresses") - - for address in address_list: - check_address(address) - - -def address_to_str(address): - if isinstance(address, tuple): - return "{0[0]}:{0[1]}".format(address) - return str(address) - - -def get_connection_id(): - global _CONNECTION_COUNTER - with _LOCK: - uid = _CONNECTION_COUNTER - _CONNECTION_COUNTER += 1 - return uid - - -def _remove_none_values(dictionary): - """Remove dictionary keys whose value is None.""" - return list(map(dictionary.pop, [i for i in dictionary if dictionary[i] is None])) - - -######################## -# # -# Errors # -# # -######################## - - -class BaseSSHTunnelForwarderError(Exception): - """Exception raised by :class:`SSHTunnelForwarder` errors""" - - def __init__(self, *args, **kwargs): - self.value = kwargs.pop("value", args[0] if args else "") - - def __str__(self): - return self.value - - -class HandlerSSHTunnelForwarderError(BaseSSHTunnelForwarderError): - """Exception for Tunnel forwarder errors""" - - pass - - -######################## -# # -# Handlers # -# # -######################## - - -class _ForwardHandler(socketserver.BaseRequestHandler): - """Base handler for tunnel connections""" - - remote_address = None - ssh_transport = None - logger = None - info = None - - def _redirect(self, chan): - while chan.active: - rqst, _, _ = select([self.request, chan], [], [], 5) - if self.request in rqst: - data = self.request.recv(1024) - if not data: - break - chan.sendall(data) - if chan in rqst: # else - if not chan.recv_ready(): - break - data = chan.recv(1024) - self.request.sendall(data) - - def handle(self): - uid = get_connection_id() - self.info = "#{0} <-- {1}".format(uid, self.client_address or self.server.local_address) - src_address = self.request.getpeername() - if not isinstance(src_address, tuple): - src_address = ("dummy", 12345) - try: - chan = self.ssh_transport.open_channel( - kind="direct-tcpip", - dest_addr=self.remote_address, - src_addr=src_address, - timeout=TUNNEL_TIMEOUT, - ) - except paramiko.SSHException: - chan = None - if chan is None: - msg = "{0} to {1} was rejected by the SSH server".format(self.info, self.remote_address) - raise HandlerSSHTunnelForwarderError(msg) - - try: - self._redirect(chan) - except socket.error: - # Sometimes a RST is sent and a socket error is raised, treat this - # exception. It was seen that a 3way FIN is processed later on, so - # no need to make an ordered close of the connection here or raise - # the exception beyond this point... - pass - except Exception: - pass - finally: - chan.close() - self.request.close() - - -class _ForwardServer(socketserver.TCPServer): # Not Threading - """ - Non-threading version of the forward server - """ - - allow_reuse_address = True # faster rebinding - - def __init__(self, *args, **kwargs): - self.logger = kwargs.pop("logger") or log - self.tunnel_ok = queue.Queue() - socketserver.TCPServer.__init__(self, *args, **kwargs) - - def handle_error(self, request, client_address): - (exc_class, exc, tb) = sys.exc_info() - self.logger.bind(source=request.getsockname()).error("Could not establish connection to remote side of the tunnel") - self.tunnel_ok.put(False) - - @property - def local_address(self): - return self.server_address - - @property - def local_host(self): - return self.server_address[0] - - @property - def local_port(self): - return self.server_address[1] - - @property - def remote_address(self): - return self.RequestHandlerClass.remote_address - - @property - def remote_host(self): - return self.RequestHandlerClass.remote_address[0] - - @property - def remote_port(self): - return self.RequestHandlerClass.remote_address[1] - - -class _ThreadingForwardServer(socketserver.ThreadingMixIn, _ForwardServer): - """ - Allow concurrent connections to each tunnel - """ - - # If True, cleanly stop threads created by ThreadingMixIn when quitting - daemon_threads = _DAEMON - - -class _UnixStreamForwardServer(UnixStreamServer): - """ - Serve over UNIX domain sockets (does not work on Windows) - """ - - def __init__(self, *args, **kwargs): - self.logger = kwargs.pop("logger") or log - self.tunnel_ok = queue.Queue() - UnixStreamServer.__init__(self, *args, **kwargs) - - @property - def local_address(self): - return self.server_address - - @property - def local_host(self): - return None - - @property - def local_port(self): - return None - - @property - def remote_address(self): - return self.RequestHandlerClass.remote_address - - @property - def remote_host(self): - return self.RequestHandlerClass.remote_address[0] - - @property - def remote_port(self): - return self.RequestHandlerClass.remote_address[1] - - -class _ThreadingUnixStreamForwardServer(socketserver.ThreadingMixIn, _UnixStreamForwardServer): - """ - Allow concurrent connections to each tunnel - """ - - # If True, cleanly stop threads created by ThreadingMixIn when quitting - daemon_threads = _DAEMON - - -class SSHTunnelForwarder: - """ - **SSH tunnel class** - - - Initialize a SSH tunnel to a remote host according to the input - arguments - - - Optionally: - + Read an SSH configuration file (typically ``~/.ssh/config``) - + Load keys from a running SSH agent (i.e. Pageant, GNOME Keyring) - - Raises: - - :class:`.BaseSSHTunnelForwarderError`: - raised by SSHTunnelForwarder class methods - - :class:`.HandlerSSHTunnelForwarderError`: - raised by tunnel forwarder threads - - .. note:: - Attributes ``mute_exceptions`` and - ``raise_exception_if_any_forwarder_have_a_problem`` - (deprecated) may be used to silence most exceptions raised - from this class - - Keyword Arguments: - - ssh_address_or_host (tuple or str): - IP or hostname of ``REMOTE GATEWAY``. It may be a two-element - tuple (``str``, ``int``) representing IP and port respectively, - or a ``str`` representing the IP address only - - .. versionadded:: 0.0.4 - - ssh_config_file (str): - SSH configuration file that will be read. If explicitly set to - ``None``, parsing of this configuration is omitted - - Default: :const:`SSH_CONFIG_FILE` - - .. versionadded:: 0.0.4 - - ssh_host_key (str): - Representation of a line in an OpenSSH-style "known hosts" - file. - - ``REMOTE GATEWAY``'s key fingerprint will be compared to this - host key in order to prevent against SSH server spoofing. - Important when using passwords in order not to accidentally - do a login attempt to a wrong (perhaps an attacker's) machine - - ssh_username (str): - Username to authenticate as in ``REMOTE SERVER`` - - Default: current local user name - - ssh_password (str): - Text representing the password used to connect to ``REMOTE - SERVER`` or for unlocking a private key. - - .. note:: - Avoid coding secret password directly in the code, since this - may be visible and make your service vulnerable to attacks - - ssh_port (int): - Optional port number of the SSH service on ``REMOTE GATEWAY``, - when `ssh_address_or_host`` is a ``str`` representing the - IP part of ``REMOTE GATEWAY``'s address - - Default: 22 - - ssh_pkey (str or paramiko.PKey): - **Private** key file name (``str``) to obtain the public key - from or a **public** key (:class:`paramiko.pkey.PKey`) - - ssh_private_key_password (str): - Password for an encrypted ``ssh_pkey`` - - .. note:: - Avoid coding secret password directly in the code, since this - may be visible and make your service vulnerable to attacks - - ssh_proxy (socket-like object or tuple): - Proxy where all SSH traffic will be passed through. - It might be for example a :class:`paramiko.proxy.ProxyCommand` - instance. - See either the :class:`paramiko.transport.Transport`'s sock - parameter documentation or ``ProxyCommand`` in ``ssh_config(5)`` - for more information. - - It is also possible to specify the proxy address as a tuple of - type (``str``, ``int``) representing proxy's IP and port - - .. note:: - Ignored if ``ssh_proxy_enabled`` is False - - .. versionadded:: 0.0.5 - - ssh_proxy_enabled (boolean): - Enable/disable SSH proxy. If True and user's - ``ssh_config_file`` contains a ``ProxyCommand`` directive - that matches the specified ``ssh_address_or_host``, - a :class:`paramiko.proxy.ProxyCommand` object will be created where - all SSH traffic will be passed through - - Default: ``True`` - - .. versionadded:: 0.0.4 - - local_bind_address (tuple): - Local tuple in the format (``str``, ``int``) representing the - IP and port of the local side of the tunnel. Both elements in - the tuple are optional so both ``('', 8000)`` and - ``('10.0.0.1', )`` are valid values - - Default: ``('0.0.0.0', RANDOM_PORT)`` - - .. versionchanged:: 0.0.8 - Added the ability to use a UNIX domain socket as local bind - address - - local_bind_addresses (list[tuple]): - In case more than one tunnel is established at once, a list - of tuples (in the same format as ``local_bind_address``) - can be specified, such as [(ip1, port_1), (ip_2, port2), ...] - - Default: ``[local_bind_address]`` - - .. versionadded:: 0.0.4 - - remote_bind_address (tuple): - Remote tuple in the format (``str``, ``int``) representing the - IP and port of the remote side of the tunnel. - - remote_bind_addresses (list[tuple]): - In case more than one tunnel is established at once, a list - of tuples (in the same format as ``remote_bind_address``) - can be specified, such as [(ip1, port_1), (ip_2, port2), ...] - - Default: ``[remote_bind_address]`` - - .. versionadded:: 0.0.4 - - allow_agent (boolean): - Enable/disable load of keys from an SSH agent - - Default: ``True`` - - .. versionadded:: 0.0.8 - - host_pkey_directories (list): - Look for pkeys in folders on this list, for example ['~/.ssh']. - - Default: ``None`` (disabled) - - .. versionadded:: 0.1.4 - - compression (boolean): - Turn on/off transport compression. By default compression is - disabled since it may negatively affect interactive sessions - - Default: ``False`` - - .. versionadded:: 0.0.8 - - logger (logging.Logger): - logging instance for sshtunnel and paramiko - - Default: :class:`logging.Logger` instance with a single - :class:`logging.StreamHandler` handler and - :const:`DEFAULT_LOGLEVEL` level - - .. versionadded:: 0.0.3 - - mute_exceptions (boolean): - Allow silencing :class:`BaseSSHTunnelForwarderError` or - :class:`HandlerSSHTunnelForwarderError` exceptions when enabled - - Default: ``False`` - - .. versionadded:: 0.0.8 - - set_keepalive (float): - Interval in seconds defining the period in which, if no data - was sent over the connection, a *'keepalive'* packet will be - sent (and ignored by the remote host). This can be useful to - keep connections alive over a NAT - - Default: 0.0 (no keepalive packets are sent) - - .. versionadded:: 0.0.7 - - threaded (boolean): - Allow concurrent connections over a single tunnel - - Default: ``True`` - - .. versionadded:: 0.0.3 - - ssh_address (str): - Superseded by ``ssh_address_or_host``, tuple of type (str, int) - representing the IP and port of ``REMOTE SERVER`` - - .. deprecated:: 0.0.4 - - ssh_host (str): - Superseded by ``ssh_address_or_host``, tuple of type - (str, int) representing the IP and port of ``REMOTE SERVER`` - - .. deprecated:: 0.0.4 - - ssh_private_key (str or paramiko.PKey): - Superseded by ``ssh_pkey``, which can represent either a - **private** key file name (``str``) or a **public** key - (:class:`paramiko.pkey.PKey`) - - .. deprecated:: 0.0.8 - - raise_exception_if_any_forwarder_have_a_problem (boolean): - Allow silencing :class:`BaseSSHTunnelForwarderError` or - :class:`HandlerSSHTunnelForwarderError` exceptions when set to - False - - Default: ``True`` - - .. versionadded:: 0.0.4 - - .. deprecated:: 0.0.8 (use ``mute_exceptions`` instead) - - Attributes: - - tunnel_is_up (dict): - Describe whether or not the other side of the tunnel was reported - to be up (and we must close it) or not (skip shutting down that - tunnel) - - .. note:: - This attribute should not be modified - - .. note:: - When :attr:`.skip_tunnel_checkup` is disabled or the local bind - is a UNIX socket, the value will always be ``True`` - - **Example**:: - - {('127.0.0.1', 55550): True, # this tunnel is up - ('127.0.0.1', 55551): False} # this one isn't - - where 55550 and 55551 are the local bind ports - - skip_tunnel_checkup (boolean): - Disable tunnel checkup (default for backwards compatibility). - - .. versionadded:: 0.1.0 - - """ - - skip_tunnel_checkup = True - daemon_forward_servers = _DAEMON #: flag tunnel threads in daemon mode - daemon_transport = _DAEMON #: flag SSH transport thread in daemon mode - - def local_is_up(self, target): - """ - Check if a tunnel is up (remote target's host is reachable on TCP - target's port) - - Arguments: - target (tuple): - tuple of type (``str``, ``int``) indicating the listen IP - address and port - Return: - boolean - - .. deprecated:: 0.1.0 - Replaced by :meth:`.check_tunnels()` and :attr:`.tunnel_is_up` - """ - try: - check_address(target) - except ValueError: - self.logger.warning( - "Target must be a tuple (IP, port), where IP " - 'is a string (i.e. "192.168.0.1") and port is ' - "an integer (i.e. 40000). Alternatively " - "target can be a valid UNIX domain socket." - ) - return False - - if self.skip_tunnel_checkup: # force tunnel check at this point - self.skip_tunnel_checkup = False - self.check_tunnels() - self.skip_tunnel_checkup = True # roll it back - return self.tunnel_is_up.get(target, True) - - def _make_ssh_forward_handler_class(self, remote_address_): - """ - Make SSH Handler class - """ - - class Handler(_ForwardHandler): - remote_address = remote_address_ - ssh_transport = self._transport - logger = self.logger - - return Handler - - def _make_ssh_forward_server_class(self, remote_address_): - return _ThreadingForwardServer if self._threaded else _ForwardServer - - def _make_unix_ssh_forward_server_class(self, remote_address_): - return _ThreadingUnixStreamForwardServer if self._threaded else _UnixStreamForwardServer - - def _make_ssh_forward_server(self, remote_address, local_bind_address): - """ - Make SSH forward proxy Server class - """ - _Handler = self._make_ssh_forward_handler_class(remote_address) - try: - if isinstance(local_bind_address, str): - forward_maker_class = self._make_unix_ssh_forward_server_class - else: - forward_maker_class = self._make_ssh_forward_server_class - _Server = forward_maker_class(remote_address) - ssh_forward_server = _Server( - local_bind_address, - _Handler, - logger=self.logger, - ) - - if ssh_forward_server: - ssh_forward_server.daemon_threads = self.daemon_forward_servers - self._server_list.append(ssh_forward_server) - self.tunnel_is_up[ssh_forward_server.server_address] = False - else: - self._raise( - BaseSSHTunnelForwarderError, - "Problem setting up ssh {0} <> {1} forwarder. You can " - "suppress this exception by using the `mute_exceptions`" - "argument".format( - address_to_str(local_bind_address), - address_to_str(remote_address), - ), - ) - except IOError: - self._raise( - BaseSSHTunnelForwarderError, - "Couldn't open tunnel {0} <> {1} might be in use or " - "destination not reachable".format( - address_to_str(local_bind_address), address_to_str(remote_address) - ), - ) - - def __init__( - self, - ssh_address_or_host=None, - ssh_config_file=SSH_CONFIG_FILE, - ssh_host_key=None, - ssh_password=None, - ssh_pkey=None, - ssh_private_key_password=None, - ssh_proxy=None, - ssh_proxy_enabled=True, - ssh_username=None, - local_bind_address=None, - local_bind_addresses=None, - logger=None, - mute_exceptions=False, - remote_bind_address=None, - remote_bind_addresses=None, - set_keepalive=0.0, - threaded=True, # old version False - compression=None, - allow_agent=True, # look for keys from an SSH agent - host_pkey_directories=None, # look for keys in ~/.ssh - gateway_timeout=None, - *args, - **kwargs, # for backwards compatibility - ) -> None: - self.logger = logger or log - self.ssh_host_key = ssh_host_key - self.set_keepalive = set_keepalive - self._server_list = [] # reset server list - self.tunnel_is_up = {} # handle tunnel status - self._threaded = threaded - self.is_alive = False - self.gateway_timeout = gateway_timeout - # Check if deprecated arguments ssh_address or ssh_host were used - for deprecated_argument in ["ssh_address", "ssh_host"]: - ssh_address_or_host = self._process_deprecated( - ssh_address_or_host, deprecated_argument, kwargs - ) - # other deprecated arguments - ssh_pkey = self._process_deprecated(ssh_pkey, "ssh_private_key", kwargs) - - self._raise_fwd_exc = ( - self._process_deprecated( - None, "raise_exception_if_any_forwarder_have_a_problem", kwargs - ) - or not mute_exceptions - ) - - if isinstance(ssh_address_or_host, tuple): - check_address(ssh_address_or_host) - (ssh_host, ssh_port) = ssh_address_or_host - else: - ssh_host = ssh_address_or_host - ssh_port = kwargs.pop("ssh_port", None) - - if kwargs: - raise ValueError("Unknown arguments: {0}".format(kwargs)) - - # remote binds - self._remote_binds = self._get_binds( - remote_bind_address, remote_bind_addresses, is_remote=True - ) - # local binds - self._local_binds = self._get_binds(local_bind_address, local_bind_addresses) - self._local_binds = self._consolidate_binds(self._local_binds, self._remote_binds) - - ( - self.ssh_host, - self.ssh_username, - ssh_pkey, # still needs to go through _consolidate_auth - self.ssh_port, - self.ssh_proxy, - self.compression, - ) = self._read_ssh_config( - ssh_host, - ssh_config_file, - ssh_username, - ssh_pkey, - ssh_port, - ssh_proxy if ssh_proxy_enabled else None, - compression, - self.logger, - ) - - (self.ssh_password, self.ssh_pkeys) = self._consolidate_auth( - ssh_password=ssh_password, - ssh_pkey=ssh_pkey, - ssh_pkey_password=ssh_private_key_password, - allow_agent=allow_agent, - host_pkey_directories=host_pkey_directories, - logger=self.logger, - ) - - check_host(self.ssh_host) - check_port(self.ssh_port) - self.logger.bind( - host=self.ssh_host, - port=self.ssh_port, - username=self.ssh_username, - timeout=self.gateway_timeout, - ).info("Connecting to gateway") - self.logger.bind(count=self._threaded).debug("Concurrent connections allowed") - - @staticmethod - def _read_ssh_config( - ssh_host, - ssh_config_file, - ssh_username=None, - ssh_pkey=None, - ssh_port=None, - ssh_proxy=None, - compression=None, - logger=log, - ): - """Read ssh_config_file. - - Read ssh_config_file and try to look for user (ssh_username), - identityfile (ssh_pkey), port (ssh_port) and proxycommand - (ssh_proxy) entries for ssh_host - """ - ssh_config = paramiko.SSHConfig() - if not ssh_config_file: # handle case where it's an empty string - ssh_config_file = None - - # Try to read SSH_CONFIG_FILE - try: - # open the ssh config file - with open(os.path.expanduser(ssh_config_file), "r") as f: - ssh_config.parse(f) - # looks for information for the destination system - hostname_info = ssh_config.lookup(ssh_host) - # gather settings for user, port and identity file - # last resort: use the 'login name' of the user - ssh_username = ssh_username or hostname_info.get("user") - ssh_pkey = ssh_pkey or hostname_info.get("identityfile", [None])[0] - ssh_host = hostname_info.get("hostname") - ssh_port = ssh_port or hostname_info.get("port") - - proxycommand = hostname_info.get("proxycommand") - ssh_proxy = ssh_proxy or (paramiko.ProxyCommand(proxycommand) if proxycommand else None) - if compression is None: - compression = hostname_info.get("compression", "") - compression = True if compression.upper() == "YES" else False - except IOError: - logger.warning("Could not read SSH configuration file: {f}", f=ssh_config_file) - except (AttributeError, TypeError): # ssh_config_file is None - logger.info("Skipping loading of ssh configuration file") - finally: - return ( - ssh_host, - ssh_username or getpass.getuser(), - ssh_pkey, - int(ssh_port) if ssh_port else 22, # fallback value - ssh_proxy, - compression, - ) - - @staticmethod - def get_agent_keys(logger=log): - """Load public keys from any available SSH agent. - - Arguments: - logger (Optional[logging.Logger]) - - Return: - list - """ - paramiko_agent = paramiko.Agent() - agent_keys = paramiko_agent.get_keys() - - logger.info("{k} keys loaded from agent", k=len(agent_keys)) - - return list(agent_keys) - - @staticmethod - def get_keys(logger=log, host_pkey_directories=None, allow_agent=False): - """Load public keys from any available SSH agent or local .ssh directory. - - Arguments: - logger (Optional[logging.Logger]) - - host_pkey_directories (Optional[list[str]]): - List of local directories where host SSH pkeys in the format - "id_*" are searched. For example, ['~/.ssh'] - - .. versionadded:: 0.1.0 - - allow_agent (Optional[boolean]): - Whether or not load keys from agent - - Default: False - - Return: - list - """ - keys = SSHTunnelForwarder.get_agent_keys(logger=logger) if allow_agent else [] - - if host_pkey_directories is not None: - paramiko_key_types = { - "rsa": paramiko.RSAKey, - "dsa": paramiko.DSSKey, - "ecdsa": paramiko.ECDSAKey, - "ed25519": paramiko.Ed25519Key, - } - for directory in host_pkey_directories or [DEFAULT_SSH_DIRECTORY]: - for keytype in paramiko_key_types.keys(): - ssh_pkey_expanded = os.path.expanduser( - os.path.join(directory, "id_{}".format(keytype)) - ) - if os.path.isfile(ssh_pkey_expanded): - ssh_pkey = SSHTunnelForwarder.read_private_key_file( - pkey_file=ssh_pkey_expanded, - logger=logger, - key_type=paramiko_key_types[keytype], - ) - if ssh_pkey: - keys.append(ssh_pkey) - - logger.info("{k} keys loaded from host directory", k=len(keys)) - - return keys - - @staticmethod - def _consolidate_binds(local_binds, remote_binds): - """Fill local_binds with defaults. - - Fill local_binds with defaults when no value/s were specified, - leaving paramiko to decide in which local port the tunnel will be open. - """ - count = len(remote_binds) - len(local_binds) - if count < 0: - raise ValueError( - "Too many local bind addresses " "(local_bind_addresses > remote_bind_addresses)" - ) - local_binds.extend([("0.0.0.0", 0) for x in range(count)]) - return local_binds - - @staticmethod - def _consolidate_auth( - ssh_password=None, - ssh_pkey=None, - ssh_pkey_password=None, - allow_agent=True, - host_pkey_directories=None, - logger=log, - ): - """Get sure authentication information is in place. - - ``ssh_pkey`` may be of classes: - - ``str`` - in this case it represents a private key file; public - key will be obtained from it - - ``paramiko.Pkey`` - it will be transparently added to loaded keys - """ - ssh_loaded_pkeys = SSHTunnelForwarder.get_keys( - logger=logger, - host_pkey_directories=host_pkey_directories, - allow_agent=allow_agent, - ) - - if isinstance(ssh_pkey, str): - ssh_pkey_expanded = os.path.expanduser(ssh_pkey) - if os.path.exists(ssh_pkey_expanded): - ssh_pkey = SSHTunnelForwarder.read_private_key_file( - pkey_file=ssh_pkey_expanded, - pkey_password=ssh_pkey_password or ssh_password, - logger=logger, - ) - else: - logger.warning("Private key file not found: {k}", k=ssh_pkey) - - if isinstance(ssh_pkey, paramiko.pkey.PKey): - ssh_loaded_pkeys.insert(0, ssh_pkey) - - if not ssh_password and not ssh_loaded_pkeys: - raise ValueError("No password or public key available!") - return (ssh_password, ssh_loaded_pkeys) - - def _raise(self, exception=BaseSSHTunnelForwarderError, reason=None): - if self._raise_fwd_exc: - raise exception(reason) - else: - self.logger.error(repr(exception(reason))) - - def _get_transport(self): - """Return the SSH transport to the remote gateway.""" - if self.ssh_proxy: - if isinstance(self.ssh_proxy, paramiko.proxy.ProxyCommand): - proxy_repr = repr(self.ssh_proxy.cmd[1]) - else: - proxy_repr = repr(self.ssh_proxy) - self.logger.debug("Connecting via proxy: {0}".format(proxy_repr)) - _socket = self.ssh_proxy - else: - _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - if isinstance(_socket, socket.socket): - _socket.settimeout(self.gateway_timeout) - _socket.connect((self.ssh_host, self.ssh_port)) - transport = paramiko.Transport(_socket) - transport.set_keepalive(self.set_keepalive) - transport.use_compression(compress=self.compression) - transport.daemon = self.daemon_transport - - return transport - - def _create_tunnels(self): - """Create SSH tunnels on top of a transport to the remote gateway.""" - if not self.is_active: - try: - self._connect_to_gateway() - except socket.gaierror: # raised by paramiko.Transport - msg = "Could not resolve IP address for {0}, aborting!".format(self.ssh_host) - self.logger.error(msg) - return - except (paramiko.SSHException, socket.error) as e: - template = "Could not connect to gateway {0}:{1} : {2}" - msg = template.format(self.ssh_host, self.ssh_port, e.args[0]) - self.logger.error(msg) - return - for (rem, loc) in zip(self._remote_binds, self._local_binds): - try: - self._make_ssh_forward_server(rem, loc) - except BaseSSHTunnelForwarderError as e: - msg = "Problem setting SSH Forwarder up: {0}".format(e.value) - self.logger.error(msg) - - @staticmethod - def _get_binds(bind_address, bind_addresses, is_remote=False): - addr_kind = "remote" if is_remote else "local" - - if not bind_address and not bind_addresses: - if is_remote: - raise ValueError( - "No {0} bind addresses specified. Use " - "'{0}_bind_address' or '{0}_bind_addresses'" - " argument".format(addr_kind) - ) - else: - return [] - elif bind_address and bind_addresses: - raise ValueError( - "You can't use both '{0}_bind_address' and " - "'{0}_bind_addresses' arguments. Use one of " - "them.".format(addr_kind) - ) - if bind_address: - bind_addresses = [bind_address] - if not is_remote: - # Add random port if missing in local bind - for (i, local_bind) in enumerate(bind_addresses): - if isinstance(local_bind, tuple) and len(local_bind) == 1: - bind_addresses[i] = (local_bind[0], 0) - check_addresses(bind_addresses, is_remote) - return bind_addresses - - @staticmethod - def _process_deprecated(attrib, deprecated_attrib, kwargs): - """Processes optional deprecate arguments.""" - - if deprecated_attrib not in DEPRECATIONS: - raise ValueError("{0} not included in deprecations list".format(deprecated_attrib)) - if deprecated_attrib in kwargs: - warnings.warn( - "'{0}' is DEPRECATED use '{1}' instead".format( - deprecated_attrib, DEPRECATIONS[deprecated_attrib] - ), - DeprecationWarning, - ) - if attrib: - raise ValueError( - "You can't use both '{0}' and '{1}'. " - "Please only use one of them".format( - deprecated_attrib, DEPRECATIONS[deprecated_attrib] - ) - ) - else: - return kwargs.pop(deprecated_attrib) - return attrib - - @staticmethod - def read_private_key_file(pkey_file, pkey_password=None, key_type=None, logger=log): - """Get SSH Public key from a private key file, given an optional password. - - Arguments: - pkey_file (str): - File containing a private key (RSA, DSS or ECDSA) - Keyword Arguments: - pkey_password (Optional[str]): - Password to decrypt the private key - logger (Optional[logging.Logger]) - Return: - paramiko.Pkey - """ - ssh_pkey = None - for pkey_class in ( - (key_type,) - if key_type - else ( - paramiko.RSAKey, - paramiko.DSSKey, - paramiko.ECDSAKey, - paramiko.Ed25519Key, - ) - ): - try: - ssh_pkey = pkey_class.from_private_key_file(pkey_file, password=pkey_password) - - logger.debug( - "Private key file ({k0}, {k1}) successfully loaded", - k0=pkey_file, - k1=pkey_class, - ) - - break - except paramiko.PasswordRequiredException: - - logger.error("Password is required for key {k}", k=pkey_file) - - break - except paramiko.SSHException: - logger.debug( - "Private key file ({k0}) could not be loaded as type {k1} or bad password", - k0=pkey_file, - k1=pkey_class, - ) - - return ssh_pkey - - def _check_tunnel(self, _srv) -> None: - """Check if tunnel is already established.""" - if self.skip_tunnel_checkup: - self.tunnel_is_up[_srv.local_address] = True - return - self.logger.debug("Checking tunnel", address=_srv.remote_address) - - if isinstance(_srv.local_address, str): # UNIX stream - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - else: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(TUNNEL_TIMEOUT) - try: - # Windows raises WinError 10049 if trying to connect to 0.0.0.0 - connect_to = ( - ("127.0.0.1", _srv.local_port) - if _srv.local_host == "0.0.0.0" - else _srv.local_address - ) - s.connect(connect_to) - self.tunnel_is_up[_srv.local_address] = _srv.tunnel_ok.get(timeout=TUNNEL_TIMEOUT * 1.1) - self.logger.bind(status="DOWN", address=_srv.remote_address).debug("Tunnel Status") - except socket.error: - self.logger.bind(status="DOWN", address=_srv.remote_address).debug("Tunnel Status") - self.tunnel_is_up[_srv.local_address] = False - - except queue.Empty: - self.logger.bind(status="UP", address=_srv.remote_address).debug("Tunnel Status") - self.tunnel_is_up[_srv.local_address] = True - finally: - s.close() - - def check_tunnels(self) -> None: - """Check that if all tunnels are established and populates. - - :attr:`.tunnel_is_up` - """ - for _srv in self._server_list: - self._check_tunnel(_srv) - - def start(self) -> None: - """Start the SSH tunnels.""" - if self.is_alive: - self.logger.warning("Already started!") - return - self._create_tunnels() - if not self.is_active: - self._raise( - BaseSSHTunnelForwarderError, - reason="Could not establish session to SSH gateway", - ) - for _srv in self._server_list: - thread = threading.Thread( - target=self._serve_forever_wrapper, - args=(_srv,), - name="Srv-{0}".format(address_to_str(_srv.local_port)), - ) - thread.daemon = self.daemon_forward_servers - thread.start() - self._check_tunnel(_srv) - self.is_alive = any(self.tunnel_is_up.values()) - if not self.is_alive: - self._raise( - HandlerSSHTunnelForwarderError, - "An error occurred while opening tunnels.", - ) - - def stop(self) -> None: - """Shut the tunnel down. - - .. note:: This **had** to be handled with care before ``0.1.0``: - - - if a port redirection is opened - - the destination is not reachable - - we attempt a connection to that tunnel (``SYN`` is sent and - acknowledged, then a ``FIN`` packet is sent and never - acknowledged... weird) - - we try to shutdown: it will not succeed until ``FIN_WAIT_2`` and - ``CLOSE_WAIT`` time out. - - .. note:: - Handle these scenarios with :attr:`.tunnel_is_up`: if False, server - ``shutdown()`` will be skipped on that tunnel - """ - self.logger.info("Closing all open connections...") - opened_address_text = ( - ", ".join((address_to_str(k.local_address) for k in self._server_list)) or "None" - ) - self.logger.debug("Listening tunnels: " + opened_address_text) - self._stop_transport() - self._server_list = [] # reset server list - self.tunnel_is_up = {} # reset tunnel status - - def close(self) -> None: - """Stop the an active tunnel, alias to :meth:`.stop`.""" - self.stop() - - def restart(self) -> None: - """Restart connection to the gateway and tunnels.""" - self.stop() - self.start() - - def _connect_to_gateway(self) -> None: - """Open connection to SSH gateway. - - - First try with all keys loaded from an SSH agent (if allowed) - - Then with those passed directly or read from ~/.ssh/config - - As last resort, try with a provided password - """ - for key in self.ssh_pkeys: - self.logger.debug( - "Trying to log in with key: {0}".format(hexlify(key.get_fingerprint())) - ) - try: - self._transport = self._get_transport() - self._transport.connect( - hostkey=self.ssh_host_key, username=self.ssh_username, pkey=key - ) - if self._transport.is_alive: - return - except paramiko.AuthenticationException: - self.logger.debug("Authentication error") - self._stop_transport() - - if self.ssh_password: # avoid conflict using both pass and pkey - self.logger.debug( - "Trying to log in with password: {0}".format("*" * len(self.ssh_password)) - ) - try: - self._transport = self._get_transport() - self._transport.connect( - hostkey=self.ssh_host_key, - username=self.ssh_username, - password=self.ssh_password, - ) - if self._transport.is_alive: - return - except paramiko.AuthenticationException: - self.logger.debug("Authentication error") - self._stop_transport() - - self.logger.error("Could not open connection to gateway") - - def _serve_forever_wrapper(self, _srv, poll_interval=0.1) -> None: - """Wrapper for the server created for a SSH forward.""" - self.logger.info( - "Opening tunnel: {0} <> {1}".format( - address_to_str(_srv.local_address), address_to_str(_srv.remote_address) - ) - ) - _srv.serve_forever(poll_interval) # blocks until finished - - self.logger.info( - "Tunnel: {0} <> {1} released".format( - address_to_str(_srv.local_address), address_to_str(_srv.remote_address) - ) - ) - - def _stop_transport(self) -> None: - """Close the underlying transport when nothing more is needed.""" - - try: - self._check_is_started() - except (BaseSSHTunnelForwarderError, HandlerSSHTunnelForwarderError) as e: - self.logger.warning(e) - for _srv in self._server_list: - tunnel = _srv.local_address - if self.tunnel_is_up[tunnel]: - self.logger.info("Shutting down tunnel {0}".format(tunnel)) - _srv.shutdown() - _srv.server_close() - # clean up the UNIX domain socket if we're using one - if isinstance(_srv, _UnixStreamForwardServer): - try: - os.unlink(_srv.local_address) - except Exception as e: - self.logger.error( - "Unable to unlink socket {0}: {1}".format(self.local_address, repr(e)) - ) - self.is_alive = False - if self.is_active: - self._transport.close() - self._transport.stop_thread() - self.logger.debug("Transport is closed") - - @property - def local_bind_port(self): - - # BACKWARDS COMPATIBILITY - self._check_is_started() - if len(self._server_list) != 1: - raise BaseSSHTunnelForwarderError( - "Use .local_bind_ports property for more than one tunnel" - ) - return self.local_bind_ports[0] - - @property - def local_bind_host(self): - - # BACKWARDS COMPATIBILITY - self._check_is_started() - if len(self._server_list) != 1: - raise BaseSSHTunnelForwarderError( - "Use .local_bind_hosts property for more than one tunnel" - ) - return self.local_bind_hosts[0] - - @property - def local_bind_address(self): - - # BACKWARDS COMPATIBILITY - self._check_is_started() - if len(self._server_list) != 1: - raise BaseSSHTunnelForwarderError( - "Use .local_bind_addresses property for more than one tunnel" - ) - return self.local_bind_addresses[0] - - @property - def local_bind_ports(self): - """Return a list containing the ports of local side of the TCP tunnels.""" - - self._check_is_started() - return [ - _server.local_port for _server in self._server_list if _server.local_port is not None - ] - - @property - def local_bind_hosts(self): - """Return a list containing the IP addresses listening for the tunnels.""" - self._check_is_started() - return [ - _server.local_host for _server in self._server_list if _server.local_host is not None - ] - - @property - def local_bind_addresses(self): - """Return a list of (IP, port) pairs for the local side of the tunnels.""" - self._check_is_started() - return [_server.local_address for _server in self._server_list] - - @property - def tunnel_bindings(self): - """Return a dictionary containing the active local<>remote tunnel_bindings.""" - return dict( - (_server.remote_address, _server.local_address) - for _server in self._server_list - if self.tunnel_is_up[_server.local_address] - ) - - @property - def is_active(self) -> bool: - """Return True if the underlying SSH transport is up""" - if "_transport" in self.__dict__ and self._transport.is_active(): - return True - return False - - def _check_is_started(self) -> None: - if not self.is_active: # underlying transport not alive - msg = "Server is not started. Please .start() first!" - raise BaseSSHTunnelForwarderError(msg) - if not self.is_alive: - msg = "Tunnels are not started. Please .start() first!" - raise HandlerSSHTunnelForwarderError(msg) - - def __str__(self) -> str: - credentials = { - "password": self.ssh_password, - "pkeys": [(key.get_name(), hexlify(key.get_fingerprint())) for key in self.ssh_pkeys] - if any(self.ssh_pkeys) - else None, - } - _remove_none_values(credentials) - template = os.linesep.join( - [ - "{0} object", - "ssh gateway: {1}:{2}", - "proxy: {3}", - "username: {4}", - "authentication: {5}", - "hostkey: {6}", - "status: {7}started", - "keepalive messages: {8}", - "tunnel connection check: {9}", - "concurrent connections: {10}allowed", - "compression: {11}requested", - "logging level: {12}", - "local binds: {13}", - "remote binds: {14}", - ] - ) - return template.format( - self.__class__, - self.ssh_host, - self.ssh_port, - self.ssh_proxy.cmd[1] if self.ssh_proxy else "no", - self.ssh_username, - credentials, - self.ssh_host_key if self.ssh_host_key else "not checked", - "" if self.is_alive else "not ", - "disabled" if not self.set_keepalive else "every {0} sec".format(self.set_keepalive), - "disabled" if self.skip_tunnel_checkup else "enabled", - "" if self._threaded else "not ", - "" if self.compression else "not ", - os.environ.get("HYPERGLASS_LOG_LEVEL") or "INFO", - self._local_binds, - self._remote_binds, - ) - - def __repr__(self) -> str: - return self.__str__() - - def __enter__(self) -> "SSHTunnelForwarder": - try: - self.start() - return self - except KeyboardInterrupt: - self.__exit__() - - def __exit__(self, *args) -> None: - self._stop_transport() - - -def open_tunnel(*args, **kwargs) -> "SSHTunnelForwarder": - """Open an SSH Tunnel, wrapper for :class:`SSHTunnelForwarder`. - - Arguments: - destination (Optional[tuple]): - SSH server's IP address and port in the format - (``ssh_address``, ``ssh_port``) - - Keyword Arguments: - debug_level (Optional[int or str]): - log level for :class:`logging.Logger` instance, i.e. ``DEBUG`` - - skip_tunnel_checkup (boolean): - Enable/disable the local side check and populate - :attr:`~SSHTunnelForwarder.tunnel_is_up` - - Default: True - - .. versionadded:: 0.1.0 - - block_on_close (boolean): - Wait until all connections are done during close by changing the - value of :attr:`~SSHTunnelForwarder.block_on_close` - - Default: True - - .. note:: - A value of ``debug_level`` set to 1 == ``TRACE`` enables tracing mode - .. note:: - See :class:`SSHTunnelForwarder` for keyword arguments - - **Example**:: - - from sshtunnel import open_tunnel - - with open_tunnel(SERVER, - ssh_username=SSH_USER, - ssh_port=22, - ssh_password=SSH_PASSWORD, - remote_bind_address=(REMOTE_HOST, REMOTE_PORT), - local_bind_address=('', LOCAL_PORT)) as server: - def do_something(port): - pass - - print("LOCAL PORTS:", server.local_bind_port) - - do_something(server.local_bind_port) - """ - # Attach a console handler to the logger or create one if not passed - kwargs["logger"] = kwargs.get("logger") or log - - ssh_address_or_host = kwargs.pop("ssh_address_or_host", None) - # Check if deprecated arguments ssh_address or ssh_host were used - for deprecated_argument in ["ssh_address", "ssh_host"]: - ssh_address_or_host = SSHTunnelForwarder._process_deprecated( - ssh_address_or_host, deprecated_argument, kwargs - ) - - ssh_port = kwargs.pop("ssh_port", 22) - skip_tunnel_checkup = kwargs.pop("skip_tunnel_checkup", True) - block_on_close = kwargs.pop("block_on_close", _DAEMON) - if not args: - if isinstance(ssh_address_or_host, tuple): - args = (ssh_address_or_host,) - else: - args = ((ssh_address_or_host, ssh_port),) - forwarder = SSHTunnelForwarder(*args, **kwargs) - forwarder.skip_tunnel_checkup = skip_tunnel_checkup - forwarder.daemon_forward_servers = not block_on_close - forwarder.daemon_transport = not block_on_close - return forwarder - - -def _bindlist(input_str): - """Define type of data expected for remote and local bind address lists. - - Returns a tuple (ip_address, port) whose elements are (str, int) - """ - try: - ip_port = input_str.split(":") - if len(ip_port) == 1: - _ip = ip_port[0] - _port = None - else: - (_ip, _port) = ip_port - if not _ip and not _port: - raise AssertionError - elif not _port: - _port = "22" # default port if not given - return _ip, int(_port) - except ValueError: - raise argparse.ArgumentTypeError("Address tuple must be of type IP_ADDRESS:PORT") - except AssertionError: - raise argparse.ArgumentTypeError("Both IP:PORT can't be missing!") diff --git a/src/stale/hyperglass/hyperglass/configuration/__init__.py b/src/stale/hyperglass/hyperglass/configuration/__init__.py deleted file mode 100644 index 4d42515..0000000 --- a/src/stale/hyperglass/hyperglass/configuration/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -"""hyperglass Configuration.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.state import use_state -from hyperglass.defaults.directives import init_builtin_directives - -# Local -from .validate import init_files, init_params, init_devices, init_ui_params, init_directives - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.directive import Directives - from hyperglass.models.config.params import Params - from hyperglass.models.config.devices import Devices - -__all__ = ("init_user_config",) - - -def init_user_config( - params: t.Optional["Params"] = None, - directives: t.Optional["Directives"] = None, - devices: t.Optional["Devices"] = None, -) -> None: - """Initialize all user configurations and add them to global state.""" - state = use_state() - init_files() - - _params = params or init_params() - builtins = init_builtin_directives() - _custom = directives or init_directives() - _directives = builtins + _custom - with state.cache.pipeline() as pipeline: - # Write params and directives to the cache first to avoid a race condition where ui_params - # or devices try to access params or directives before they're available. - pipeline.set("params", _params) - pipeline.set("directives", _directives) - - _devices = devices or init_devices() - ui_params = init_ui_params(params=_params, devices=_devices) - with state.cache.pipeline() as pipeline: - pipeline.set("devices", _devices) - pipeline.set("ui_params", ui_params) diff --git a/src/stale/hyperglass/hyperglass/configuration/load.py b/src/stale/hyperglass/hyperglass/configuration/load.py deleted file mode 100644 index 0ea6dab..0000000 --- a/src/stale/hyperglass/hyperglass/configuration/load.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Collect configurations from files.""" - -# Standard Library -import typing as t -from pathlib import Path - -# Project -from hyperglass.log import log -from hyperglass.util import run_coroutine_in_new_thread -from hyperglass.settings import Settings -from hyperglass.constants import CONFIG_EXTENSIONS -from hyperglass.exceptions.private import ConfigError, ConfigMissing, ConfigLoaderMissing - -LoadedConfig = t.Union[t.Dict[str, t.Any], t.List[t.Any], t.Tuple[t.Any, ...]] - - -def find_path(file_name: str, *, required: bool) -> t.Union[Path, None]: - """Find the first matching configuration file.""" - for extension in CONFIG_EXTENSIONS: - path = Settings.app_path / f"{file_name}.{extension}" - if path.exists(): - return path - - if required: - raise ConfigMissing(file_name, app_path=Settings.app_path) - return None - - -def load_dsl(path: Path, *, empty_allowed: bool) -> LoadedConfig: - """Verify and load data from DSL (non-python) config files.""" - loader = None - if path.suffix in (".yaml", ".yml"): - try: - # Third Party - import yaml - - loader = yaml.safe_load - - except ImportError as err: - raise ConfigLoaderMissing(path) from err - elif path.suffix == ".toml": - try: - # Third Party - import toml - - loader = toml.load - - except ImportError as err: - raise ConfigLoaderMissing(path) from err - - elif path.suffix == ".json": - # Standard Library - import json - - loader = json.load - - if loader is None: - raise ConfigLoaderMissing(path) - - with path.open("r") as f: - data = loader(f) - if data is None and empty_allowed is False: - raise ConfigError( - "'{!s}' exists, but it is empty and is required to start hyperglass.".format(path), - ) - log.bind(path=path).debug("Loaded configuration") - return data or {} - - -def load_python(path: Path, *, empty_allowed: bool) -> LoadedConfig: - """Import configuration from a python configuration file.""" - # Standard Library - import inspect - from importlib.util import module_from_spec, spec_from_file_location - - # Load the file as a module. - name, _ = path.name.split(".") - spec = spec_from_file_location(name, location=path) - module = module_from_spec(spec) - spec.loader.exec_module(module) - # Get all exports that are named 'main' (any case). - exports = tuple(getattr(module, e, None) for e in dir(module) if e.lower() == "main") - if len(exports) < 1: - # Raise an error if there are no exports named main. - raise ConfigError( - f"'{path!s} exists', but it is missing a variable or function named 'main'" - ) - # Pick the first export named main. - main, *_ = exports - data = None - if isinstance(main, t.Callable): - if inspect.iscoroutinefunction(main): - # Resolve an async funcion. - data = run_coroutine_in_new_thread(main) - else: - # Resolve a standard function. - data = main() - elif isinstance(main, (t.Dict, t.List, t.Tuple)): - data = main - - if data is None and empty_allowed is False: - raise ConfigError(f"'{path!s} exists', but variable or function 'main' is an invalid type") - - log.bind(path=path).debug("Loaded configuration") - return data or {} - - -def load_config(name: str, *, required: bool) -> LoadedConfig: - """Load a configuration file.""" - path = find_path(name, required=required) - - if path is None and required is False: - return {} - - if path.suffix == ".py": - return load_python(path, empty_allowed=not required) - - if path.suffix.replace(".", "") in CONFIG_EXTENSIONS: - return load_dsl(path, empty_allowed=not required) - - raise ConfigError( - "{p} has an unsupported file extension. Must be one of {e}", - p=path, - e=", ".join(CONFIG_EXTENSIONS), - ) diff --git a/src/stale/hyperglass/hyperglass/configuration/markdown.py b/src/stale/hyperglass/hyperglass/configuration/markdown.py deleted file mode 100644 index 3df2762..0000000 --- a/src/stale/hyperglass/hyperglass/configuration/markdown.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Markdown processing utility functions.""" - -# Standard Library -import typing as t -from pathlib import Path - -if t.TYPE_CHECKING: - # Project - from hyperglass.models import HyperglassModel - - -def get_markdown(config: "HyperglassModel", default: str, params: t.Dict[str, t.Any]) -> str: - """Get markdown file if specified, or use default.""" - - if config.enable and config.file is not None: - # with config_path.file - if hasattr(config, "file") and isinstance(config.file, Path): - with config.file.open("r") as config_file: - md = config_file.read() - else: - md = default - - try: - return md.format(**params) - except KeyError: - return md diff --git a/src/stale/hyperglass/hyperglass/configuration/tests/__init__.py b/src/stale/hyperglass/hyperglass/configuration/tests/__init__.py deleted file mode 100644 index 233c95a..0000000 --- a/src/stale/hyperglass/hyperglass/configuration/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""hyperglass configuration tests.""" diff --git a/src/stale/hyperglass/hyperglass/configuration/tests/test_load.py b/src/stale/hyperglass/hyperglass/configuration/tests/test_load.py deleted file mode 100644 index dba92e9..0000000 --- a/src/stale/hyperglass/hyperglass/configuration/tests/test_load.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Test configuration file collection.""" - -# Standard Library -import tempfile -from pathlib import Path - -# Project -from hyperglass.settings import Settings - -# Local -from ..load import load_config - -TOML = """ -test = "from toml" -""" - -YAML = """ -test: from yaml -""" - -JSON = """ -{"test": "from json"} -""" - -PY_VARIABLE = """ -MAIN = {'test': 'from python variable'} -""" - -PY_FUNCTION = """ -def main(): - return {'test': 'from python function'} -""" - -PY_COROUTINE = """ -async def main(): - return {'test': 'from python coroutine'} -""" - -CASES = ( - ("test.toml", "from toml", TOML), - ("test.yaml", "from yaml", YAML), - ("test_py_variable.py", "from python variable", PY_VARIABLE), - ("test_py_function.py", "from python function", PY_FUNCTION), - ("test_py_coroutine.py", "from python coroutine", PY_COROUTINE), -) - - -def test_collect(monkeypatch): - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - monkeypatch.setattr(Settings, "app_path", directory) - for name, value, data in CASES: - path = directory / Path(name) - with path.open("w") as p: - p.write(data) - loaded = load_config(path.stem, required=True) - assert loaded.get("test") is not None - assert loaded["test"] == value diff --git a/src/stale/hyperglass/hyperglass/configuration/validate.py b/src/stale/hyperglass/hyperglass/configuration/validate.py deleted file mode 100644 index 3accb7e..0000000 --- a/src/stale/hyperglass/hyperglass/configuration/validate.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Import configuration files and run validation.""" - -# Third Party -from pydantic import ValidationError - -# Project -from hyperglass.log import log -from hyperglass.settings import Settings -from hyperglass.models.ui import UIParameters -from hyperglass.models.directive import Directive, Directives -from hyperglass.exceptions.private import ConfigError, ConfigInvalid -from hyperglass.models.config.params import Params -from hyperglass.models.config.devices import Devices - -# Local -from .load import load_config -from .markdown import get_markdown - -__all__ = ( - "init_devices", - "init_directives", - "init_files", - "init_params", - "init_ui_params", -) - - -def init_files() -> None: - """Check if required directories exist and if not, create them.""" - for directory in ("plugins", "static/images"): - path = Settings.app_path / directory - if not path.exists(): - path.mkdir(parents=True) - log.debug("Created directory", path=path) - - -def init_params() -> "Params": - """Validate & initialize configuration parameters.""" - user_config = load_config("config", required=False) - # Map imported user configuration to expected schema. - params = Params(**user_config) - - # # Set up file logging once configuration parameters are initialized. - # enable_file_logging( - # log_directory=params.logging.directory, - # log_format=params.logging.format, - # log_max_size=params.logging.max_size, - # debug=Settings.debug, - # ) - - # Set up syslog logging if enabled. - # if params.logging.syslog is not None and params.logging.syslog.enable: - # enable_syslog_logging( - # syslog_host=params.logging.syslog.host, - # syslog_port=params.logging.syslog.port, - # ) - - if params.logging.http is not None and params.logging.http.enable: - log.debug("HTTP logging is enabled") - - # Perform post-config initialization string formatting or other - # functions that require access to other config levels. E.g., - # something in 'params.web.text' needs to be formatted with a value - # from params. - try: - params.web.text.subtitle = params.web.text.subtitle.format( - **params.model_dump(exclude={"web", "queries", "messages"}) - ) - except KeyError: - pass - - return params - - -def init_directives() -> "Directives": - """Validate & initialize directives.""" - # Map imported user directives to expected schema. - directives = load_config("directives", required=False) - try: - directives = ( - Directive(id=name, **directive) - for name, directive in load_config("directives", required=False).items() - ) - - except ValidationError as err: - raise ConfigInvalid(errors=err.errors()) from err - - return Directives(*directives) - - -def init_devices() -> "Devices": - """Validate & initialize devices.""" - devices_config = load_config("devices", required=True) - items = [] - - # Support first matching main key name. - for key in ("main", "devices", "routers"): - if key in devices_config: - items = devices_config[key] - break - - if len(items) < 1: - raise ConfigError("No devices are defined in devices file") - - devices = Devices(*items) - log.debug("Initialized devices", devices=devices) - - return devices - - -def init_ui_params(*, params: "Params", devices: "Devices") -> "UIParameters": - """Validate & initialize UI parameters.""" - - # Project - from hyperglass.defaults import CREDIT - from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__ - - content_greeting = get_markdown( - config=params.web.greeting, - default="", - params={"title": params.web.greeting.title}, - ) - content_credit = CREDIT.format(version=__version__) - - _ui_params = params.frontend() - _ui_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix - _ui_params["web"]["logo"]["dark_format"] = params.web.logo.dark.suffix - - return UIParameters( - **_ui_params, - version=__version__, - devices=devices.frontend(), - developer_mode=Settings.dev_mode, - parsed_data_fields=PARSED_RESPONSE_FIELDS, - content={"credit": content_credit, "greeting": content_greeting}, - ) diff --git a/src/stale/hyperglass/hyperglass/console.py b/src/stale/hyperglass/hyperglass/console.py deleted file mode 100755 index bdc2d07..0000000 --- a/src/stale/hyperglass/hyperglass/console.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 -"""hyperglass CLI management tool.""" - -# Local -from .cli import run - -if __name__ == "__main__": - run() diff --git a/src/stale/hyperglass/hyperglass/constants.py b/src/stale/hyperglass/hyperglass/constants.py deleted file mode 100644 index 0139c74..0000000 --- a/src/stale/hyperglass/hyperglass/constants.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Constant definitions used throughout the application.""" - -# Standard Library -from datetime import datetime - -__name__ = "hyperglass" -__version__ = "2.0.4" -__author__ = "Matt Love" -__copyright__ = f"Copyright {datetime.now().year} Matthew Love" -__license__ = "BSD 3-Clause Clear License" - -METADATA = (__name__, __version__, __author__, __copyright__, __license__) - -MIN_PYTHON_VERSION = (3, 8) - -MIN_NODE_VERSION = 18 - -TARGET_FORMAT_SPACE = ("huawei", "huawei_vrpv8") - -TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") - -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") - -CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") - -STATUS_CODE_MAP = {"warning": 400, "error": 400, "danger": 500} - -DNS_OVER_HTTPS = { - "google": "https://dns.google/resolve", - "cloudflare": "https://cloudflare-dns.com/dns-query", -} - -PARSED_RESPONSE_FIELDS = ( - ("Prefix", "prefix", "left"), - ("Active", "active", None), - ("RPKI State", "rpki_state", "center"), - ("AS Path", "as_path", "left"), - ("Next Hop", "next_hop", "left"), - ("Origin", "source_as", None), - ("Weight", "weight", "center"), - ("Local Preference", "local_preference", "center"), - ("MED", "med", "center"), - ("Communities", "communities", "center"), - ("Originator", "source_rid", "right"), - ("Peer", "peer_rid", "right"), - ("Age", "age", "right"), -) - -SUPPORTED_QUERY_FIELDS = ("query_location", "query_type", "query_target", "query_vrf") -SUPPORTED_QUERY_TYPES = ( - "bgp_route", - "bgp_community", - "bgp_aspath", - "ping", - "traceroute", -) - -FUNC_COLOR_MAP = { - "primary": "cyan", - "secondary": "blue", - "success": "green", - "warning": "yellow", - "error": "orange", - "danger": "red", -} - -TRANSPORT_REST = ("frr_legacy", "bird_legacy") - -SCRAPE_HELPERS = { - "arista": "arista_eos", - "ios": "cisco_ios", - "juniper_junos": "juniper", - "junos": "juniper", - "mikrotik": "mikrotik_routeros", - "tsnr": "tnsr", -} - -DRIVER_MAP = { - "bird": "netmiko", - "frr": "netmiko", - "openbgpd": "netmiko", - "http": "hyperglass_http_client", -} - -LINUX_PLATFORMS = ( - "frr", - "bird", - "openbgpd", -) diff --git a/src/stale/hyperglass/hyperglass/defaults/__init__.py b/src/stale/hyperglass/hyperglass/defaults/__init__.py deleted file mode 100644 index ce1f492..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Default or built-in hyperglass data.""" - -# Local -from ._strings import CREDIT, DEFAULT_HELP, DEFAULT_TERMS, DEFAULT_DETAILS - -__all__ = ( - "CREDIT", - "DEFAULT_TERMS", - "DEFAULT_DETAILS", - "DEFAULT_HELP", -) diff --git a/src/stale/hyperglass/hyperglass/defaults/_strings.py b/src/stale/hyperglass/hyperglass/defaults/_strings.py deleted file mode 100644 index e1ae145..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/_strings.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Constant store for large default values.""" - -CREDIT = """ -Powered by [**hyperglass**](https://hyperglass.dev) version {version}. \ -Source code licensed [_BSD 3-Clause Clear_](https://hyperglass.dev/license/). -""" - -DEFAULT_TERMS = """ -By using {site_title}, you agree to be bound by the following terms of use: - -All queries executed on this page are logged for analysis and troubleshooting. \ -Users are prohibited from automating queries, or attempting to process queries in \ -bulk. This service is provided on a best effort basis, and {org_name} \ -makes no availability or performance warranties or guarantees whatsoever. -""" - -DEFAULT_DETAILS = { - "bgp_aspath": """ -{site_title} accepts the following `AS_PATH` regular expression patterns: - -| Expression | Match | -| :------------------- | :-------------------------------------------- | -| `_65000$` | Originated by 65000 | -| `^65000_` | Received from 65000 | -| `_65000_` | Via 65000 | -| `_65000_65001_` | Via 65000 and 65001 | -| `_65000(_.+_)65001$` | Anything from 65001 that passed through 65000 | -""", - "bgp_community": """ -{site_title} makes use of the following BGP communities: - -| Community | Description | -| :-------- | :---------- | -| `65000:1` | Example 1 | -| `65000:2` | Example 2 | -| `65000:3` | Example 3 | -""", - "bgp_route": """ -Performs BGP table lookup based on IPv4/IPv6 prefix. -""", - "ping": """ -Sends 5 ICMP echo requests to the target. -""", - "traceroute": """ -Performs UDP Based traceroute to the target. \ -For information about how to interpret traceroute results, [click here]\ -(https://hyperglass.dev/traceroute_nanog.pdf). -""", -} - -DEFAULT_HELP = """ -##### BGP Route - -Performs BGP table lookup based on IPv4/IPv6 prefix. - ---- - -##### BGP Community - -Performs BGP table lookup based on [Extended](https://tools.ietf.org/html/rfc4360) \ -or [Large](https://tools.ietf.org/html/rfc8195) community value. - ---- - -##### BGP AS Path - -Performs BGP table lookup based on `AS_PATH` regular expression. - ---- - -##### Ping - -Sends 5 ICMP echo requests to the target. - ---- - -##### Traceroute - -Performs UDP Based traceroute to the target. - -For information about how to interpret traceroute results, [click here]\ -(https://hyperglass.dev/traceroute_nanog.pdf). -""" diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/__init__.py b/src/stale/hyperglass/hyperglass/defaults/directives/__init__.py deleted file mode 100644 index 8b29355..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Built-in hyperglass directives.""" - -# Standard Library -import pkgutil -import importlib -from pathlib import Path - -# Project -from hyperglass.log import log -from hyperglass.models.directive import Directives - - -def init_builtin_directives() -> "Directives": - """Find all directives and register them with global state manager.""" - directives_dir = Path(__file__).parent - directives = () - for _, name, __ in pkgutil.iter_modules([directives_dir]): - module = importlib.import_module(f"hyperglass.defaults.directives.{name}") - - if not all((hasattr(module, "__all__"), len(getattr(module, "__all__", ())) > 0)): - # Warn if there is no __all__ export or if it is empty. - log.warning("Module '{!s}' is missing an '__all__' export", module) - - exports = (getattr(module, p) for p in module.__all__ if hasattr(module, p)) - directives += (*exports,) - return Directives(*directives) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/arista_eos.py b/src/stale/hyperglass/hyperglass/defaults/directives/arista_eos.py deleted file mode 100644 index 5d1bad7..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/arista_eos.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Default Arista Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "AristaBGPRoute", - "AristaBGPASPath", - "AristaBGPCommunity", - "AristaPing", - "AristaTraceroute", - "AristaBGPRouteTable", - "AristaBGPASPathTable", - "AristaBGPCommunityTable", -) - -NAME = "Arista EOS" -PLATFORMS = ["arista_eos"] - -AristaBGPRoute = BuiltinDirective( - id="__hyperglass_arista_eos_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show ip bgp {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show ipv6 bgp {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - table_output="__hyperglass_arista_eos_bgp_route_table__", - platforms=PLATFORMS, -) - -AristaBGPASPath = BuiltinDirective( - id="__hyperglass_arista_eos_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show ip bgp regexp {target}", - "show ipv6 bgp regexp {target}", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - table_output="__hyperglass_arista_eos_bgp_aspath_table__", - platforms=PLATFORMS, -) - -AristaBGPCommunity = BuiltinDirective( - id="__hyperglass_arista_eos_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show ip bgp community {target}", - "show ipv6 bgp community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - table_output="__hyperglass_arista_eos_bgp_community_table__", - platforms=PLATFORMS, -) - - -AristaPing = BuiltinDirective( - id="__hyperglass_arista_eos_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping ip {target} source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping ipv6 {target} source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -AristaTraceroute = BuiltinDirective( - id="__hyperglass_arista_eos_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute ip {target} source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute ipv6 {target} source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -# Table Output Directives - -AristaBGPRouteTable = BuiltinDirective( - id="__hyperglass_arista_eos_bgp_route_table__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show ip bgp {target} | json", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show ipv6 bgp {target} | json", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -AristaBGPASPathTable = BuiltinDirective( - id="__hyperglass_arista_eos_bgp_aspath_table__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show ip bgp regexp {target} | json", - "show ipv6 bgp regexp {target} | json", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -AristaBGPCommunityTable = BuiltinDirective( - id="__hyperglass_arista_eos_bgp_community_table__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show ip bgp community {target} | json", - "show ipv6 bgp community {target} | json", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/bird.py b/src/stale/hyperglass/hyperglass/defaults/directives/bird.py deleted file mode 100644 index 14c9712..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/bird.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Default BIRD Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "BIRD_BGPASPath", - "BIRD_BGPCommunity", - "BIRD_BGPRoute", - "BIRD_Ping", - "BIRD_Traceroute", -) - -NAME = "BIRD" -PLATFORMS = ["bird"] - -BIRD_BGPRoute = BuiltinDirective( - id="__hyperglass_bird_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command='birdc "show route all where {target} ~ net"', - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command='birdc "show route all where {target} ~ net"', - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -BIRD_BGPASPath = BuiltinDirective( - id="__hyperglass_bird_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'birdc "show route all where bgp_path ~ {target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -BIRD_BGPCommunity = BuiltinDirective( - id="__hyperglass_bird_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'birdc "show route all where {target} ~ bgp_community"', - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -BIRD_Ping = BuiltinDirective( - id="__hyperglass_bird_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping -4 -c 5 -I {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping -6 -c 5 -I {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -BIRD_Traceroute = BuiltinDirective( - id="__hyperglass_bird_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute -4 -w 1 -q 1 -s {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute -6 -w 1 -q 1 -s {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/cisco_ios.py b/src/stale/hyperglass/hyperglass/defaults/directives/cisco_ios.py deleted file mode 100644 index f4327c9..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/cisco_ios.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default Cisco IOS Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "CiscoIOS_BGPASPath", - "CiscoIOS_BGPCommunity", - "CiscoIOS_BGPRoute", - "CiscoIOS_Ping", - "CiscoIOS_Traceroute", -) - -NAME = "Cisco IOS" -PLATFORMS = ["cisco_ios"] - -CiscoIOS_BGPRoute = BuiltinDirective( - id="__hyperglass_cisco_ios_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show bgp ipv4 unicast {target} | exclude pathid:|Epoch", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show bgp ipv6 unicast {target} | exclude pathid:|Epoch", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -CiscoIOS_BGPASPath = BuiltinDirective( - id="__hyperglass_cisco_ios_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'show bgp ipv4 unicast quote-regexp "{target}"', - 'show bgp ipv6 unicast quote-regexp "{target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -CiscoIOS_BGPCommunity = BuiltinDirective( - id="__hyperglass_cisco_ios_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show bgp ipv4 unicast community {target}", - "show bgp ipv6 unicast community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -CiscoIOS_Ping = BuiltinDirective( - id="__hyperglass_cisco_ios_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping {target} repeat 5 source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping ipv6 {target} repeat 5 source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -CiscoIOS_Traceroute = BuiltinDirective( - id="__hyperglass_cisco_ios_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute {target} timeout 1 probe 2 source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute ipv6 {target} timeout 1 probe 2 source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/cisco_nxos.py b/src/stale/hyperglass/hyperglass/defaults/directives/cisco_nxos.py deleted file mode 100644 index 1c7a8ea..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/cisco_nxos.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default Cisco NX-OS Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "CiscoNXOS_BGPASPath", - "CiscoNXOS_BGPCommunity", - "CiscoNXOS_BGPRoute", - "CiscoNXOS_Ping", - "CiscoNXOS_Traceroute", -) - -NAME = "Cisco NX-OS" -PLATFORMS = ["cisco_nxos"] - -CiscoNXOS_BGPRoute = BuiltinDirective( - id="__hyperglass_cisco_nxos_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show bgp ipv4 unicast {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show bgp ipv6 unicast {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -CiscoNXOS_BGPASPath = BuiltinDirective( - id="__hyperglass_cisco_nxos_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'show bgp ipv4 unicast regexp "{target}"', - 'show bgp ipv6 unicast regexp "{target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -CiscoNXOS_BGPCommunity = BuiltinDirective( - id="__hyperglass_cisco_nxos_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show bgp ipv4 unicast community {target}", - "show bgp ipv6 unicast community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -CiscoNXOS_Ping = BuiltinDirective( - id="__hyperglass_cisco_nxos_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping {target} source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping6 {target} source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -CiscoNXOS_Traceroute = BuiltinDirective( - id="__hyperglass_cisco_nxos_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute {target} source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute6 {target} source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/cisco_xr.py b/src/stale/hyperglass/hyperglass/defaults/directives/cisco_xr.py deleted file mode 100644 index 8450180..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/cisco_xr.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default Cisco IOS-XR Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "CiscoXR_BGPASPath", - "CiscoXR_BGPCommunity", - "CiscoXR_BGPRoute", - "CiscoXR_Ping", - "CiscoXR_Traceroute", -) - -NAME = "Cisco IOS-XR" -PLATFORMS = ["cisco_xr"] - -CiscoXR_BGPRoute = BuiltinDirective( - id="__hyperglass_cisco_xr_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show bgp ipv4 unicast {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show bgp ipv6 unicast {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -CiscoXR_BGPASPath = BuiltinDirective( - id="__hyperglass_cisco_xr_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show bgp ipv4 unicast regexp {target}", - "show bgp ipv6 unicast regexp {target}", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -CiscoXR_BGPCommunity = BuiltinDirective( - id="__hyperglass_cisco_xr_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show bgp ipv4 unicast community {target}", - "show bgp ipv6 unicast community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -CiscoXR_Ping = BuiltinDirective( - id="__hyperglass_cisco_xr_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping ipv4 {target} count 5 source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping ipv6 {target} count 5 source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -CiscoXR_Traceroute = BuiltinDirective( - id="__hyperglass_cisco_xr_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute ipv4 {target} timeout 1 probe 2 source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute ipv6 {target} timeout 1 probe 2 source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/frr.py b/src/stale/hyperglass/hyperglass/defaults/directives/frr.py deleted file mode 100644 index eb6baaf..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/frr.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Default FRRouting Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "FRRouting_BGPASPath", - "FRRouting_BGPCommunity", - "FRRouting_BGPRoute", - "FRRouting_Ping", - "FRRouting_Traceroute", - "FRRouting_BGPRouteTable", -) - -NAME = "FRRouting" -PLATFORMS = ["frr"] - -FRRouting_BGPRoute = BuiltinDirective( - id="__hyperglass_frr_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command='vtysh -c "show bgp ipv4 unicast {target}"', - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command='vtysh -c "show bgp ipv6 unicast {target}"', - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - table_output="__hyperglass_frr_bgp_route_table__", - platforms=PLATFORMS, -) - -FRRouting_BGPASPath = BuiltinDirective( - id="__hyperglass_frr_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'vtysh -c "show bgp ipv4 unicast regexp {target}"', - 'vtysh -c "show bgp ipv6 unicast regexp {target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -FRRouting_BGPCommunity = BuiltinDirective( - id="__hyperglass_frr_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'vtysh -c "show bgp ipv4 unicast community {target}"', - 'vtysh -c "show bgp ipv6 unicast community {target}"', - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -FRRouting_Ping = BuiltinDirective( - id="__hyperglass_frr_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping -4 -c 5 -I {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping -6 -c 5 -I {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -FRRouting_Traceroute = BuiltinDirective( - id="__hyperglass_frr_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute -4 -w 1 -q 1 -s {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute -6 -w 1 -q 1 -s {source6} {target}", - ), - ], - 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/src/stale/hyperglass/hyperglass/defaults/directives/huawei.py b/src/stale/hyperglass/hyperglass/defaults/directives/huawei.py deleted file mode 100644 index f8d3ef0..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/huawei.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Default Huawei Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "Huawei_BGPASPath", - "Huawei_BGPCommunity", - "Huawei_BGPRoute", - "Huawei_Ping", - "Huawei_Traceroute", -) - -NAME = "Huawei VRP" -PLATFORMS = ["huawei", "huawei_vrpv8"] - -Huawei_BGPRoute = BuiltinDirective( - id="__hyperglass_huawei_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="display bgp routing-table {target} | no-more", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="display bgp ipv6 routing-table {target} | no-more", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], - platforms=PLATFORMS, -) - -Huawei_BGPASPath = BuiltinDirective( - id="__hyperglass_huawei_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "display bgp routing-table regular-expression {target}", - "display bgp ipv6 routing-table regular-expression {target}", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -Huawei_BGPCommunity = BuiltinDirective( - id="__hyperglass_huawei_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "display bgp routing-table community {target}", - "display bgp ipv6 routing-table community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -Huawei_Ping = BuiltinDirective( - id="__hyperglass_huawei_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping -c 5 -a {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping ipv6 -c 5 -a {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -Huawei_Traceroute = BuiltinDirective( - id="__hyperglass_huawei_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="tracert -q 2 -f 1 -a {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="tracert -q 2 -f 1 -a {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/juniper.py b/src/stale/hyperglass/hyperglass/defaults/directives/juniper.py deleted file mode 100644 index dda31e1..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/juniper.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Default Juniper Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "JuniperBGPRoute", - "JuniperBGPASPath", - "JuniperBGPCommunity", - "JuniperPing", - "JuniperTraceroute", - "JuniperBGPRouteTable", - "JuniperBGPASPathTable", - "JuniperBGPCommunityTable", -) - -NAME = "Juniper Junos" -PLATFORMS = ["juniper", "juniper_junos"] - -JuniperBGPRoute = BuiltinDirective( - id="__hyperglass_juniper_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show route protocol bgp table inet.0 {target} detail", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show route protocol bgp table inet6.0 {target} detail", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - table_output="__hyperglass_juniper_bgp_route_table__", - platforms=PLATFORMS, -) - -JuniperBGPASPath = BuiltinDirective( - id="__hyperglass_juniper_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'show route protocol bgp table inet.0 aspath-regex "{target}"', - 'show route protocol bgp table inet6.0 aspath-regex "{target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - table_output="__hyperglass_juniper_bgp_aspath_table__", - platforms=PLATFORMS, -) - -JuniperBGPCommunity = BuiltinDirective( - id="__hyperglass_juniper_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'show route protocol bgp table inet.0 community "{target}" detail', - 'show route protocol bgp table inet6.0 community "{target}" detail', - ], - ) - ], - field=Text(description="BGP Community String"), - table_output="__hyperglass_juniper_bgp_community_table__", - platforms=PLATFORMS, -) - - -JuniperPing = BuiltinDirective( - id="__hyperglass_juniper_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping inet {target} count 5 source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping inet6 {target} count 5 source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -JuniperTraceroute = BuiltinDirective( - id="__hyperglass_juniper_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute inet {target} wait 1 source {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute inet6 {target} wait 2 source {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -# Table Output Directives - -JuniperBGPRouteTable = BuiltinDirective( - id="__hyperglass_juniper_bgp_route_table__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show route protocol bgp table inet.0 {target} best detail | display xml", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show route protocol bgp table inet6.0 {target} best detail | display xml", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -JuniperBGPASPathTable = BuiltinDirective( - id="__hyperglass_juniper_bgp_aspath_table__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'show route protocol bgp table inet.0 aspath-regex "{target}" detail | display xml', - 'show route protocol bgp table inet6.0 aspath-regex "{target}" detail | display xml', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -JuniperBGPCommunityTable = BuiltinDirective( - id="__hyperglass_juniper_bgp_community_table__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show route protocol bgp table inet.0 community {target} detail | display xml", - "show route protocol bgp table inet6.0 community {target} detail | display xml", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/mikrotik.py b/src/stale/hyperglass/hyperglass/defaults/directives/mikrotik.py deleted file mode 100644 index 4ea96fd..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/mikrotik.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default Mikrotik Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "Mikrotik_BGPASPath", - "Mikrotik_BGPCommunity", - "Mikrotik_BGPRoute", - "Mikrotik_Ping", - "Mikrotik_Traceroute", -) - -NAME = "Mikrotik" -PLATFORMS = ["mikrotik_routeros", "mikrotik_switchos"] - -Mikrotik_BGPRoute = BuiltinDirective( - id="__hyperglass_mikrotik_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ip route print where {target} in dst-address", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ipv6 route print where {target} in dst-address", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -Mikrotik_BGPASPath = BuiltinDirective( - id="__hyperglass_mikrotik_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "ip route print where bgp-as-path={target}", - "ipv6 route print where bgp-as-path={target}", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -Mikrotik_BGPCommunity = BuiltinDirective( - id="__hyperglass_mikrotik_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "ip route print where bgp-communities={target}", - "ipv6 route print where bgp-communities={target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -Mikrotik_Ping = BuiltinDirective( - id="__hyperglass_mikrotik_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping src-address={source4} count=5 {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping src-address={source6} count=5 {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -Mikrotik_Traceroute = BuiltinDirective( - id="__hyperglass_mikrotik_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="tool traceroute src-address={source4} timeout=1 duration=5 count=1 {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="tool traceroute src-address={source6} timeout=1 duration=5 count=1 {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/nokia_sros.py b/src/stale/hyperglass/hyperglass/defaults/directives/nokia_sros.py deleted file mode 100644 index 1e8ea5e..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/nokia_sros.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Default Nokia SR-OS Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "NokiaSROS_BGPASPath", - "NokiaSROS_BGPCommunity", - "NokiaSROS_BGPRoute", - "NokiaSROS_Ping", - "NokiaSROS_Traceroute", -) - -NAME = "Nokia SR OS" -PLATFORMS = ["nokia_sros"] - -NokiaSROS_BGPRoute = BuiltinDirective( - id="__hyperglass_nokia_sros_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="/show router bgp routes {target} ipv4 hunt", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="/show router bgp routes {target} ipv6 hunt", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -NokiaSROS_BGPASPath = BuiltinDirective( - id="__hyperglass_nokia_sros_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "/show router bgp routes aspath-regex {target}", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -NokiaSROS_BGPCommunity = BuiltinDirective( - id="__hyperglass_nokia_sros_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "/show router bgp routes community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -NokiaSROS_Ping = BuiltinDirective( - id="__hyperglass_nokia_sros_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="/ping {target} source-address {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="/ping {target} source-address {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -NokiaSROS_Traceroute = BuiltinDirective( - id="__hyperglass_nokia_sros_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="/traceroute {target} source-address {source4} wait 2 seconds", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="/traceroute {target} source-address {source6} wait 2 seconds", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/openbgpd.py b/src/stale/hyperglass/hyperglass/defaults/directives/openbgpd.py deleted file mode 100644 index 71e7077..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/openbgpd.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default FRRouting Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "OpenBGPD_BGPASPath", - "OpenBGPD_BGPCommunity", - "OpenBGPD_BGPRoute", - "OpenBGPD_Ping", - "OpenBGPD_Traceroute", -) - -NAME = "OpenBGPD" -PLATFORMS = ["openbgpd"] - -OpenBGPD_BGPRoute = BuiltinDirective( - id="__hyperglass_openbgpd_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="bgpctl show rib inet {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="bgpctl show rib inet6 {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -OpenBGPD_BGPASPath = BuiltinDirective( - id="__hyperglass_openbgpd_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "bgpctl show rib inet as {target}", - "bgpctl show rib inet6 as {target}", - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -OpenBGPD_BGPCommunity = BuiltinDirective( - id="__hyperglass_openbgpd_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "bgpctl show rib inet community {target}", - "bgpctl show rib inet6 community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -OpenBGPD_Ping = BuiltinDirective( - id="__hyperglass_openbgpd_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping -4 -c 5 -I {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping -6 -c 5 -I {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -OpenBGPD_Traceroute = BuiltinDirective( - id="__hyperglass_openbgpd_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute -4 -w 1 -q 1 -s {source4} {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute -6 -w 1 -q 1 -s {source6} {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/tnsr.py b/src/stale/hyperglass/hyperglass/defaults/directives/tnsr.py deleted file mode 100644 index cea4681..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/tnsr.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default TNSR Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "TNSR_BGPASPath", - "TNSR_BGPCommunity", - "TNSR_BGPRoute", - "TNSR_Ping", - "TNSR_Traceroute", -) - -NAME = "TNSR" -PLATFORMS = ["tnsr"] - -TNSR_BGPRoute = BuiltinDirective( - id="__hyperglass_tnsr_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command='dataplane shell sudo vtysh -c "show bgp ipv4 unicast {target}"', - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command='dataplane shell sudo vtysh -c "show bgp ipv6 unicast {target}"', - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -TNSR_BGPASPath = BuiltinDirective( - id="__hyperglass_tnsr_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'dataplane shell sudo vtysh -c "show bgp ipv4 unicast regexp {target}"', - 'dataplane shell sudo vtysh -c "show bgp ipv6 unicast regexp {target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -TNSR_BGPCommunity = BuiltinDirective( - id="__hyperglass_tnsr_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'dataplane shell sudo vtysh -c "show bgp ipv4 unicast community {target}"', - 'dataplane shell sudo vtysh -c "show bgp ipv6 unicast community {target}"', - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -TNSR_Ping = BuiltinDirective( - id="__hyperglass_tnsr_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping {target} ipv4 source {source4} count 5 timeout 1", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping {target} ipv6 source {source6} count 5 timeout 1", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -TNSR_Traceroute = BuiltinDirective( - id="__hyperglass_tnsr_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute {target} ipv4 source {source4} timeout 1 waittime 1", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute {target} ipv6 source {source6} timeout 1 waittime 1", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/defaults/directives/vyos.py b/src/stale/hyperglass/hyperglass/defaults/directives/vyos.py deleted file mode 100644 index 6aec00a..0000000 --- a/src/stale/hyperglass/hyperglass/defaults/directives/vyos.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Default VyOS Directives.""" - -# Project -from hyperglass.models.directive import ( - Text, - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - BuiltinDirective, -) - -__all__ = ( - "VyOS_BGPASPath", - "VyOS_BGPCommunity", - "VyOS_BGPRoute", - "VyOS_Ping", - "VyOS_Traceroute", -) - -NAME = "VyOS" -PLATFORMS = ["vyos"] - -VyOS_BGPRoute = BuiltinDirective( - id="__hyperglass_vyos_bgp_route__", - name="BGP Route", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="show bgp ipv4 {target}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="show bgp ipv6 {target}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -VyOS_BGPASPath = BuiltinDirective( - id="__hyperglass_vyos_bgp_aspath__", - name="BGP AS Path", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - 'show bgp ipv4 regexp "{target}"', - 'show bgp ipv6 regexp "{target}"', - ], - ) - ], - field=Text(description="AS Path Regular Expression"), - platforms=PLATFORMS, -) - -VyOS_BGPCommunity = BuiltinDirective( - id="__hyperglass_vyos_bgp_community__", - name="BGP Community", - rules=[ - RuleWithPattern( - condition="*", - action="permit", - commands=[ - "show bgp ipv4 community {target}", - "show bgp ipv6 community {target}", - ], - ) - ], - field=Text(description="BGP Community String"), - platforms=PLATFORMS, -) - -VyOS_Ping = BuiltinDirective( - id="__hyperglass_vyos_ping__", - name="Ping", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="ping {target} count 5 interface {source4}", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="ping {target} count 5 interface {source6}", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) - -VyOS_Traceroute = BuiltinDirective( - id="__hyperglass_vyos_traceroute__", - name="Traceroute", - rules=[ - RuleWithIPv4( - condition="0.0.0.0/0", - action="permit", - command="traceroute {target} source-address {source4} icmp", - ), - RuleWithIPv6( - condition="::/0", - action="permit", - command="traceroute {target} source-address {source6} icmp", - ), - ], - field=Text(description="IP Address, Prefix, or Hostname"), - platforms=PLATFORMS, -) diff --git a/src/stale/hyperglass/hyperglass/exceptions/__init__.py b/src/stale/hyperglass/hyperglass/exceptions/__init__.py deleted file mode 100644 index 0f3fb9e..0000000 --- a/src/stale/hyperglass/hyperglass/exceptions/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Custom exceptions for hyperglass.""" - -# Local -from ._common import HyperglassError, PublicHyperglassError, PrivateHyperglassError - -__all__ = ( - "HyperglassError", - "PublicHyperglassError", - "PrivateHyperglassError", -) diff --git a/src/stale/hyperglass/hyperglass/exceptions/_common.py b/src/stale/hyperglass/hyperglass/exceptions/_common.py deleted file mode 100644 index 1f04f94..0000000 --- a/src/stale/hyperglass/hyperglass/exceptions/_common.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Custom exceptions for hyperglass.""" - -# Standard Library -import json as _json -from typing import Any, Set, Dict, List, Union, Literal, Optional - -# Third Party -from pydantic import ValidationError - -# Project -from hyperglass.log import log -from hyperglass.util import get_fmt_keys, repr_from_attrs -from hyperglass.constants import STATUS_CODE_MAP - -ErrorLevel = Literal["danger", "warning"] - - -class HyperglassError(Exception): - """hyperglass base exception.""" - - def __init__( - self, - message: str = "", - level: ErrorLevel = "warning", - keywords: Optional[List[str]] = None, - ) -> None: - """Initialize the hyperglass base exception class.""" - self._message = message - self._level = level - self._keywords = keywords or [] - if self._level == "warning": - log.error(str(self)) - elif self._level == "danger": - log.critical(str(self)) - else: - log.info(str(self)) - - def __str__(self) -> str: - """Return the instance's error message.""" - return self._message - - def __repr__(self) -> str: - """Return the instance's severity & error message in a string.""" - return repr_from_attrs(self, ("_message", "level", "keywords"), strip="_") - - def dict(self) -> Dict[str, Union[str, List[str]]]: - """Return the instance's attributes as a dictionary.""" - return { - "message": self._message, - "level": self._level, - "keywords": self.keywords, - } - - def json(self) -> str: - """Return the instance's attributes as a JSON object.""" - return _json.dumps(self.__dict__()) - - @staticmethod - def _safe_format(template: str, **kwargs: Dict[str, str]) -> str: - """Safely format a string template from keyword arguments.""" - - keys = get_fmt_keys(template) - for key in keys: - if key not in kwargs: - kwargs.pop(key) - else: - kwargs[key] = str(kwargs[key]) - return template.format(**kwargs) - - def _parse_pydantic_errors(*errors: Dict[str, Any]) -> str: - errs = ("\n",) - - for err in errors: - loc = " → ".join(str(loc) for loc in err["loc"]) - errs += (f"Field: {loc}\n Error: {err['msg']}\n",) - - return "\n".join(errs) - - def _process_keywords(self) -> None: - out: Set[str] = set() - for val in self._keywords: - if isinstance(val, str): - out.add(val) - elif isinstance(val, list): - for v in val: - out.add(v) - else: - out.add(str(val)) - self._keywords = list(out) - - @property - def message(self) -> str: - """Return the instance's `message` attribute.""" - return self._message - - @property - def level(self) -> str: - """Return the instance's `level` attribute.""" - return self._level - - @property - def keywords(self) -> List[str]: - """Return the instance's `keywords` attribute.""" - self._process_keywords() - return self._keywords - - @property - def status_code(self) -> int: - """Return HTTP status code based on level level.""" - return STATUS_CODE_MAP.get(self._level, 500) - - -class PublicHyperglassError(HyperglassError): - """Base exception class for user-facing errors. - - Error text should be defined in - `hyperglass.configuration.params.messages` and associated with the - exception class at start time. - """ - - _level = "warning" - _message_template = "Something went wrong." - _original_template_name: str = "" - - def __init_subclass__( - cls, *, template: Optional[str] = None, level: Optional[ErrorLevel] = None - ) -> None: - """Override error attributes from subclass.""" - - if template is not None: - cls._message_template = template - cls._original_template_name = template - if level is not None: - cls._level = level - - def __init__(self, **kwargs: str) -> None: - """Format error message with keyword arguments.""" - # Project - from hyperglass.state import use_state - - if "error" in kwargs: - error = kwargs.pop("error") - error = self._safe_format(str(error), **kwargs) - kwargs["error"] = error - - template = self._message_template - - (messages := use_state("params").messages) - if messages.has(self._original_template_name): - template = messages[self._original_template_name] - if "error" in kwargs and "({error})" not in template: - template += " ({error})" - self._message = self._safe_format(template, **kwargs) - self._keywords = list(kwargs.values()) - super().__init__(message=self._message, level=self._level, keywords=self._keywords) - - -class PrivateHyperglassError(HyperglassError): - """Base exception class for internal system errors. - - Error text is dynamic based on the exception being caught. - """ - - _level = "warning" - - def _parse_validation_error(self, err: ValidationError) -> str: - errors = err.errors() - parsed = { - k: ", ".join(str(loc) for t in errors for loc in t["loc"] if t["type"] == k) - for k in {e["type"] for e in errors} - } - return ", ".join(parsed.values()) - - def __init_subclass__(cls, *, level: Optional[ErrorLevel] = None) -> None: - """Override error attributes from subclass.""" - if level is not None: - cls._level = level - - def __init__(self, message: str, **kwargs: Any) -> None: - """Format error message with keyword arguments.""" - if "error" in kwargs: - error = kwargs.pop("error") - error = self._safe_format(str(error), **kwargs) - kwargs["error"] = error - - if isinstance(message, ValidationError): - message = self._parse_validation_error(message) - - self._message = self._safe_format(message, **kwargs) - self._keywords = list(kwargs.values()) - super().__init__(message=self._message, level=self._level, keywords=self._keywords) diff --git a/src/stale/hyperglass/hyperglass/exceptions/private.py b/src/stale/hyperglass/hyperglass/exceptions/private.py deleted file mode 100644 index 80b5c8d..0000000 --- a/src/stale/hyperglass/hyperglass/exceptions/private.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Internal/private exceptions.""" - -# Standard Library -from typing import Any, Dict, List -from pathlib import Path - -# Project -from hyperglass.constants import CONFIG_EXTENSIONS - -# Local -from ._common import ErrorLevel, PrivateHyperglassError - - -class ExternalError(PrivateHyperglassError): - """Raised when an error during a connection to an external service occurs.""" - - def __init__(self, message: str, level: ErrorLevel, **kwargs: Dict[str, Any]) -> None: - """Set level according to level argument.""" - self._level = level - super().__init__(message, **kwargs) - - -class UnsupportedDevice(PrivateHyperglassError): - """Raised when an input platform is not in the supported platform list.""" - - def __init__(self, platform: str) -> None: - """Show the unsupported device type and a list of supported drivers.""" - # Third Party - from netmiko.ssh_dispatcher import CLASS_MAPPER # type: ignore - - # Project - from hyperglass.constants import DRIVER_MAP - - sorted_drivers = sorted([*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()]) - driver_list = "\n - ".join(("", *sorted_drivers)) - super().__init__(message=f"'{platform}' is not supported. Must be one of:{driver_list}") - - -class InputValidationError(PrivateHyperglassError): - """Raised when a validation check fails. - - This needs to be separate from `hyperglass.exceptions.public` for - circular import reasons. - """ - - kwargs: Dict[str, Any] - - def __init__(self, **kwargs: Dict[str, Any]) -> None: - """Set kwargs instance attribute so it can be consumed later. - - `hyperglass.exceptions.public.InputInvalid` will be raised from - these kwargs. - """ - self.kwargs = kwargs - super().__init__(message="", **kwargs) - - -class ConfigInvalid(PrivateHyperglassError): - """Raised when a config item fails type or option validation.""" - - def __init__(self, errors: List[Dict[str, Any]]) -> None: - """Parse Pydantic ValidationError.""" - - super().__init__(message=self._parse_pydantic_errors(*errors)) - - -class ConfigMissing(PrivateHyperglassError): - """Raised when a required config file or item is missing or undefined.""" - - def __init__(self, file_name: str, *, app_path: Path) -> None: - """Customize error message.""" - message = " ".join( - ( - file_name.capitalize(), - "file is missing in", - f"'{app_path!s}', and is required to start hyperglass.", - "Supported file names are:", - ", ".join(f"'{file_name}.{e}'" for e in CONFIG_EXTENSIONS), - ". Please consult the installation documentation.", - ) - ) - super().__init__(message) - - -class ConfigLoaderMissing(PrivateHyperglassError): - """Raised when a configuration file is using a file extension that requires a missing loader.""" - - def __init__(self, path: Path, /) -> None: - """Customize error message.""" - message = "'{path}' requires a {loader} loader, but it is not installed" - super().__init__(message=message, path=path, loader=path.suffix.strip(".")) - - -class ConfigError(PrivateHyperglassError): - """Raised for generic user-config issues.""" - - -class UnsupportedError(PrivateHyperglassError): - """Raised when an unsupported action or request occurs.""" - - -class ParsingError(PrivateHyperglassError): - """Raised when there is a problem parsing a structured response.""" - - -class DependencyError(PrivateHyperglassError): - """Raised when a dependency is missing, not running, or on the wrong version.""" - - -class PluginError(PrivateHyperglassError): - """Raised when a plugin error occurs.""" - - -class StateError(PrivateHyperglassError): - """Raised when an error occurs while fetching state from Redis.""" diff --git a/src/stale/hyperglass/hyperglass/exceptions/public.py b/src/stale/hyperglass/hyperglass/exceptions/public.py deleted file mode 100644 index 912e63b..0000000 --- a/src/stale/hyperglass/hyperglass/exceptions/public.py +++ /dev/null @@ -1,148 +0,0 @@ -"""User-facing/Public exceptions.""" - -# Standard Library -from typing import TYPE_CHECKING, Any, Dict, Optional - -# Local -from ._common import PublicHyperglassError - -if TYPE_CHECKING: - # Project - from hyperglass.models.api.query import Query - from hyperglass.models.config.devices import Device - - -class ScrapeError( - PublicHyperglassError, - template="connection_error", - level="danger", -): - """Raised when an SSH driver error occurs.""" - - def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name, proxy=device.proxy) - - -class AuthError(PublicHyperglassError, template="authentication_error", level="danger"): - """Raised when authentication to a device fails.""" - - def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name, proxy=device.proxy) - - -class RestError(PublicHyperglassError, template="connection_error", level="danger"): - """Raised upon a rest API client error.""" - - def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name) - - -class DeviceTimeout(PublicHyperglassError, template="request_timeout", level="danger"): - """Raised when the connection to a device times out.""" - - def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name, proxy=device.proxy) - - -class InvalidQuery(PublicHyperglassError, template="request_timeout"): - """Raised when input validation fails.""" - - def __init__( - self, *, error: Optional[str] = None, query: "Query", **kwargs: Dict[str, Any] - ) -> None: - """Initialize parent error.""" - - kwargs = { - "query_type": query.query_type, - "target": query.query_target, - "error": str(error), - **kwargs, - } - - super().__init__(**kwargs) - - -class NotFound(PublicHyperglassError, template="not_found"): - """Raised when an object is not found.""" - - def __init__(self, type: str, name: str, **kwargs: Dict[str, str]) -> None: - """Initialize parent error.""" - super().__init__(type=type, name=name, **kwargs) - - -class QueryLocationNotFound(NotFound): - """Raised when a query location is not found.""" - - def __init__(self, location: Any, **kwargs: Dict[str, Any]) -> None: - """Initialize a NotFound error for a query location.""" - # Project - from hyperglass.state import use_state - - (text := use_state("params").web.text) - - super().__init__(type=text.query_location, name=str(location), **kwargs) - - -class QueryTypeNotFound(NotFound): - """Raised when a query type is not found.""" - - def __init__(self, query_type: Any, **kwargs: Dict[str, Any]) -> None: - """Initialize a NotFound error for a query type.""" - # Project - from hyperglass.state import use_state - - (text := use_state("params").web.text) - super().__init__(type=text.query_type, name=str(query_type), **kwargs) - - -class InputInvalid(PublicHyperglassError, template="invalid_input"): - """Raised when input validation fails.""" - - def __init__( - self, *, error: Optional[Any] = None, target: str, **kwargs: Dict[str, Any] - ) -> None: - """Initialize parent error.""" - - kwargs = {"target": target, "error": str(error), **kwargs} - - super().__init__(**kwargs) - - -class InputNotAllowed(PublicHyperglassError, template="target_not_allowed"): - """Raised when input validation fails due to a configured check.""" - - def __init__( - self, *, error: Optional[str] = None, query: "Query", **kwargs: Dict[str, Any] - ) -> None: - """Initialize parent error.""" - - kwargs = { - "query_type": query.query_type, - "target": query.query_target, - "error": str(error), - **kwargs, - } - - super().__init__(**kwargs) - - -class ResponseEmpty(PublicHyperglassError, template="no_output"): - """Raised when hyperglass can connect to the device but the response is empty.""" - - def __init__( - self, *, error: Optional[str] = None, query: "Query", **kwargs: Dict[str, Any] - ) -> None: - """Initialize parent error.""" - - kwargs = { - "query_type": query.query_type, - "target": query.query_target, - "error": str(error), - **kwargs, - } - - super().__init__(**kwargs) diff --git a/src/stale/hyperglass/hyperglass/execution/__init__.py b/src/stale/hyperglass/hyperglass/execution/__init__.py deleted file mode 100644 index ec33dd5..0000000 --- a/src/stale/hyperglass/hyperglass/execution/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Validate, construct, execute queries. - -Constructs SSH commands or API call parameters based on front end -input, executes the commands/calls, returns the output to front end. -""" diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/__init__.py b/src/stale/hyperglass/hyperglass/execution/drivers/__init__.py deleted file mode 100644 index 1ebbab4..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Individual transport driver classes & subclasses.""" - -# Local -from ._common import Connection -from .http_client import HttpClient -from .ssh_netmiko import NetmikoConnection - -__all__ = ( - "Connection", - "HttpClient", - "NetmikoConnection", -) diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/_common.py b/src/stale/hyperglass/hyperglass/execution/drivers/_common.py deleted file mode 100644 index bec6778..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/_common.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Base Connection Class.""" - -# Standard Library -import typing as t -from abc import ABC, abstractmethod - -# Project -from hyperglass.types import Series -from hyperglass.plugins import OutputPluginManager - -# Local -from ._construct import Construct - -if t.TYPE_CHECKING: - # Project - from hyperglass.compat import SSHTunnelForwarder - from hyperglass.models.api import Query - from hyperglass.models.data import OutputDataModel - from hyperglass.models.config.devices import Device - - -class Connection(ABC): - """Base transport driver class.""" - - def __init__(self, device: "Device", query_data: "Query") -> None: - """Initialize connection to device.""" - self.device = device - self.query_data = query_data - self.query_type = self.query_data.query_type - self.query_target = self.query_data.query_target - self._query = Construct(device=self.device, query=self.query_data) - self.query = self._query.queries() - self.plugin_manager = OutputPluginManager() - - @abstractmethod - def setup_proxy(self: "Connection") -> "SSHTunnelForwarder": - """Return a preconfigured sshtunnel.SSHTunnelForwarder instance.""" - pass - - async def response(self, output: Series[str]) -> t.Union["OutputDataModel", str]: - """Send output through common parsers.""" - - response = self.plugin_manager.execute(output=output, query=self.query_data) - - if response is None: - response = () - - return response diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/_construct.py b/src/stale/hyperglass/hyperglass/execution/drivers/_construct.py deleted file mode 100644 index c1d1e2b..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/_construct.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Construct SSH command/API parameters from validated query data. - -Accepts filtered & validated input from execute.py, constructs SSH -command for Netmiko library or API call parameters for supported -hyperglass API modules. -""" - -# Standard Library -import re -import json as _json -import typing as t -import ipaddress - -# Project -from hyperglass.log import log -from hyperglass.util import get_fmt_keys -from hyperglass.constants import TRANSPORT_REST, TARGET_FORMAT_SPACE -from hyperglass.exceptions.public import InputInvalid -from hyperglass.exceptions.private import ConfigError - -if t.TYPE_CHECKING: - # Third Party - from loguru import Logger - - # Project - from hyperglass.models.api.query import Query - from hyperglass.models.directive import Directive - from hyperglass.models.config.devices import Device - -FormatterCallback = t.Callable[[str], t.Union[t.List[str], str]] - - -class Construct: - """Construct SSH commands/REST API parameters from validated query data.""" - - directive: "Directive" - device: "Device" - query: "Query" - transport: str - target: str - _log: "Logger" - - def __init__(self, device: "Device", query: "Query"): - """Initialize command construction.""" - self._log = log.bind(type=query.query_type, target=query.query_target) - self._log.debug("Constructing query") - self.query = query - self.device = device - self.target = self.query.query_target - self.directive = query.directive - - # Set transport method based on NOS type - self.transport = "scrape" - if self.device.platform in TRANSPORT_REST: - self.transport = "rest" - - # Remove slashes from target for required platforms - if self.device.platform in TARGET_FORMAT_SPACE: - self.target = re.sub(r"\/", r" ", str(self.query.query_target)) - - with Formatter(self.query) as formatter: - self.target = formatter(self.prepare_target()) - - def prepare_target(self) -> t.Union[t.List[str], str]: - """Format the query target based on directive parameters.""" - if isinstance(self.query.query_target, t.List): - # Directive can accept multiple values in a single command. - if self.directive.multiple: - return self.directive.multiple_separator.join(self.query.query_target) - # Target is an array of one, return single item. - if len(self.query.query_target) == 1: - return self.query.query_target[0] - # Directive commands should be run once for each item in the target. - - return self.query.query_target - - def json(self, afi): - """Return JSON version of validated query for REST devices.""" - self._log.debug("Building JSON query") - return _json.dumps( - { - "query_type": self.query.query_type, - "vrf": self.query.query_vrf.name, - "afi": afi.protocol, - "source": str(afi.source_address), - "target": str(self.target), - } - ) - - def format(self, command: str) -> str: - """Return formatted command for 'Scrape' endpoints (SSH).""" - keys = get_fmt_keys(command) - attrs = {k: v for k, v in self.device.attrs.items() if k in keys} - 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}'"), - level="danger", - c=self.directive.name, - k=key, - d=self.device.name, - ) - - mask = ipaddress.ip_address("255.255.255.255") - try: - network = ipaddress.ip_network(self.target) - if network.version == 4 and network.network_address != network.broadcast_address: - # Network is an IPv4 network with more than one host. - mask = network.netmask - except ValueError: - pass - - return command.format(target=self.target, mask=mask, **attrs) - - def queries(self): - """Return queries for each enabled AFI.""" - query = [] - - rules = [r for r in self.directive.rules if r._passed is True] - if len(rules) < 1: - raise InputInvalid( - error="No validation rules matched target '{target}'", - target=self.query.query_target, - ) - - for rule in [r for r in self.directive.rules if r._passed is True]: - for command in rule.commands: - query.append(self.format(command)) - self._log.bind(constructed_query=query).debug("Constructed query") - return query - - -class Formatter: - """Modify query target based on the device's NOS requirements and the query type.""" - - def __init__(self, query: "Query") -> None: - """Initialize target formatting.""" - self.query = query - self.platform = query.device.platform - self.query_type = query.query_type - - def __enter__(self): - """Get the relevant formatter.""" - return self._get_formatter() - - def __exit__(self, exc_type, exc_value, exc_traceback): - """Handle context exit.""" - if exc_type is not None: - log.error(exc_traceback) - pass - - def _get_formatter(self): - if self.platform in ("juniper", "juniper_junos"): - if self.query_type == "bgp_aspath": - return self._with_formatter(self._juniper_bgp_aspath) - if self.platform in ("bird", "bird_ssh"): - if self.query_type == "bgp_aspath": - return self._with_formatter(self._bird_bgp_aspath) - if self.query_type == "bgp_community": - return self._with_formatter(self._bird_bgp_community) - return self._with_formatter(self._default) - - def _default(self, target: str) -> str: - """Don't format targets by default.""" - return target - - def _with_formatter(self, formatter: t.Callable[[str], str]) -> FormatterCallback: - result: FormatterCallback - if isinstance(self.query.query_target, t.List): - result = lambda s: [formatter(i) for i in s] - result = lambda s: formatter(s) - return result - - def _juniper_bgp_aspath(self, target: str) -> str: - """Convert from Cisco AS_PATH format to Juniper format.""" - query = str(target) - asns = re.findall(r"\d+", query) - was_modified = False - - if bool(re.match(r"^\_", query)): - # Replace `_65000` with `.* 65000` - asns.insert(0, r".*") - was_modified = True - - if bool(re.match(r".*(\_)$", query)): - # Replace `65000_` with `65000 .*` - asns.append(r".*") - was_modified = True - - if was_modified: - modified = " ".join(asns) - log.bind(original=target, modified=modified).debug("Modified target") - return modified - - return query - - def _bird_bgp_aspath(self, target: str) -> str: - """Convert from Cisco AS_PATH format to BIRD format.""" - - # Extract ASNs from query target string - asns = re.findall(r"\d+", target) - was_modified = False - - if bool(re.match(r"^\_", target)): - # Replace `_65000` with `.* 65000` - asns.insert(0, "*") - was_modified = True - - if bool(re.match(r".*(\_)$", target)): - # Replace `65000_` with `65000 .*` - asns.append("*") - was_modified = True - - asns.insert(0, "[=") - asns.append("=]") - - result = " ".join(asns) - - if was_modified: - log.bind(original=target, modified=result).debug("Modified target") - - return result - - def _bird_bgp_community(self, target: str) -> str: - """Convert from standard community format to BIRD format.""" - parts = target.split(":") - return f"({','.join(parts)})" diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/http_client.py b/src/stale/hyperglass/hyperglass/execution/drivers/http_client.py deleted file mode 100644 index c2ad2ae..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/http_client.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Interact with an http-based device.""" - -# Standard Library -import typing as t - -# Third Party -import httpx - -# Project -from hyperglass.util import get_fmt_keys -from hyperglass.exceptions.public import AuthError, RestError, DeviceTimeout, ResponseEmpty - -# Local -from ._common import Connection - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.api import Query - from hyperglass.models.config.devices import Device - from hyperglass.models.config.http_client import HttpConfiguration - - -class HttpClient(Connection): - """Interact with an http-based device.""" - - config: "HttpConfiguration" - client: httpx.AsyncClient - - def __init__(self, device: "Device", query_data: "Query") -> None: - """Initialize base connection and set http config & client.""" - super().__init__(device, query_data) - self.config = device.http - self.client = self.config.create_client(device=device) - - def setup_proxy(self: "Connection"): - """HTTP Client does not support SSH proxies.""" - raise NotImplementedError("HTTP Client does not support SSH proxies.") - - def _query_params(self) -> t.Dict[str, str]: - if self.config.query is None: - return { - self.config._attribute_map.query_target: self.query_data.query_target, - self.config._attribute_map.query_location: self.query_data.query_location, - self.config._attribute_map.query_type: self.query_data.query_type, - } - if isinstance(self.config.query, t.Dict): - return { - key: value.format( - **{ - str(v): str(getattr(self.query_data, k, None)) - for k, v in self.config.attribute_map.model_dump().items() - if v in get_fmt_keys(value) - } - ) - for key, value in self.config.query.items() - } - return {} - - def _body(self) -> t.Dict[str, t.Union[t.Dict[str, t.Any], str]]: - data = { - self.config._attribute_map.query_target: self.query_data.query_target, - self.config._attribute_map.query_location: self.query_data.query_location, - self.config._attribute_map.query_type: self.query_data.query_type, - } - if self.config.body_format == "json": - return {"json": data} - - if self.config.body_format == "yaml": - # Third Party - import yaml - - return {"content": yaml.dump(data), "headers": {"content-type": "text/yaml"}} - - if self.config.body_format == "xml": - # Third Party - import xmltodict # type: ignore - - return { - "content": xmltodict.unparse({"query": data}), - "headers": {"content-type": "application/xml"}, - } - if self.config.body_format == "text": - return {"data": data} - - return {} - - async def collect(self, *args: t.Any, **kwargs: t.Any) -> t.Iterable: - """Collect response data from an HTTP endpoint.""" - - query = self._query_params() - responses = () - - async with self.client as client: - body = {} - if self.config.method in ("POST", "PATCH", "PUT"): - body = self._body() - - try: - response: httpx.Response = await client.request( - method=self.config.method, url=self.config.path, params=query, **body - ) - response.raise_for_status() - data = response.text.strip() - - if len(data) == 0: - raise ResponseEmpty(query=self.query_data) - - responses += (data,) - - except httpx.TimeoutException as error: - raise DeviceTimeout(error=error, device=self.device) from error - - except httpx.HTTPStatusError as error: - if error.response.status_code == 401: - raise AuthError(error=error, device=self.device) from error - raise RestError(error=error, device=self.device) from error - return responses diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/ssh.py b/src/stale/hyperglass/hyperglass/execution/drivers/ssh.py deleted file mode 100644 index 90401d8..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/ssh.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Common Classes or Utilities for SSH Drivers.""" - -# Standard Library -from typing import TYPE_CHECKING - -# Project -from hyperglass.log import log -from hyperglass.state import use_state -from hyperglass.compat import BaseSSHTunnelForwarderError, open_tunnel -from hyperglass.exceptions.public import ScrapeError - -# Local -from ._common import Connection - -if TYPE_CHECKING: - # Project - from hyperglass.compat import SSHTunnelForwarder - - -class SSHConnection(Connection): - """Base class for SSH drivers.""" - - def setup_proxy(self) -> "SSHTunnelForwarder": - """Return a preconfigured sshtunnel.SSHTunnelForwarder instance.""" - - proxy = self.device.proxy - params = use_state("params") - - def opener(): - """Set up an SSH tunnel according to a device's configuration.""" - tunnel_kwargs = { - "ssh_username": proxy.credential.username, - "remote_bind_address": (self.device._target, self.device.port), - "local_bind_address": ("localhost", 0), - "skip_tunnel_checkup": False, - "gateway_timeout": params.request_timeout - 2, - } - if proxy.credential._method == "password": - # Use password auth if no key is defined. - tunnel_kwargs["ssh_password"] = proxy.credential.password.get_secret_value() - else: - # Otherwise, use key auth. - tunnel_kwargs["ssh_pkey"] = proxy.credential.key.as_posix() - 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() - ) - try: - return open_tunnel( - ssh_address_or_host=proxy._target, ssh_port=proxy.port, **tunnel_kwargs - ) - - except BaseSSHTunnelForwarderError as scrape_proxy_error: - log.bind(device=self.device.name, proxy=proxy.name).error( - "Failed to connect to device via proxy" - ) - raise ScrapeError( - error=scrape_proxy_error, device=self.device - ) from scrape_proxy_error - - return opener diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/ssh_netmiko.py b/src/stale/hyperglass/hyperglass/execution/drivers/ssh_netmiko.py deleted file mode 100644 index e96f68c..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/ssh_netmiko.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Netmiko-Specific Classes & Utilities. - -https://github.com/ktbyers/netmiko -""" - -# Standard Library -import math -from typing import Iterable - -# Third Party -from netmiko import ( # type: ignore - ConnectHandler, - NetMikoTimeoutException, - NetMikoAuthenticationException, -) - -# Project -from hyperglass.log import log -from hyperglass.state import use_state -from hyperglass.exceptions.public import AuthError, DeviceTimeout, ResponseEmpty - -# Local -from .ssh import SSHConnection - -netmiko_device_globals = { - # Netmiko doesn't currently handle Mikrotik echo verification well, - # see ktbyers/netmiko#1600 - "mikrotik_routeros": {"global_cmd_verify": False}, - "mikrotik_switchos": {"global_cmd_verify": False}, -} - -netmiko_device_send_args = {} - - -class NetmikoConnection(SSHConnection): - """Handle a device connection via Netmiko.""" - - async def collect(self, host: str = None, port: int = None) -> Iterable: - """Connect directly to a device. - - Directly connects to the router via Netmiko library, returns the - command output. - """ - params = use_state("params") - _log = log.bind( - device=self.device.name, - address=f"{host}:{port}", - proxy=str(self.device.proxy.address) if self.device.proxy is not None else None, - ) - - _log.debug("Connecting to device") - - global_args = netmiko_device_globals.get(self.device.platform, {}) - - send_args = netmiko_device_send_args.get(self.device.platform, {}) - - driver_kwargs = { - "host": host or self.device._target, - "port": port or self.device.port, - "device_type": self.device.get_device_type(), - "username": self.device.credential.username, - "global_delay_factor": 0.1, - "timeout": math.floor(params.request_timeout * 1.25), - "session_timeout": math.ceil(params.request_timeout - 1), - **global_args, - **self.device.driver_config, - } - - if "_telnet" in self.device.platform: - # Telnet devices with a low delay factor (default) tend to - # throw login errors. - driver_kwargs["global_delay_factor"] = 2 - - if self.device.credential._method == "password": - # Use password auth if no key is defined. - driver_kwargs["password"] = self.device.credential.password.get_secret_value() - else: - # Otherwise, use key auth. - driver_kwargs["use_keys"] = True - driver_kwargs["key_file"] = self.device.credential.key - if self.device.credential._method == "encrypted_key": - # If the key is encrypted, use the password field as the - # private key password. - driver_kwargs["passphrase"] = self.device.credential.password.get_secret_value() - - try: - nm_connect_direct = ConnectHandler(**driver_kwargs) - - responses = () - - for query in self.query: - raw = nm_connect_direct.send_command(query, **send_args) - responses += (raw,) - - nm_connect_direct.disconnect() - - except NetMikoTimeoutException as scrape_error: - raise DeviceTimeout(error=scrape_error, device=self.device) from scrape_error - - except NetMikoAuthenticationException as auth_error: - raise AuthError(error=auth_error, device=self.device) from auth_error - - if not responses: - raise ResponseEmpty(query=self.query_data) - - return responses diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/tests/__init__.py b/src/stale/hyperglass/hyperglass/execution/drivers/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/stale/hyperglass/hyperglass/execution/drivers/tests/test_construct.py b/src/stale/hyperglass/hyperglass/execution/drivers/tests/test_construct.py deleted file mode 100644 index 7e5adee..0000000 --- a/src/stale/hyperglass/hyperglass/execution/drivers/tests/test_construct.py +++ /dev/null @@ -1,90 +0,0 @@ -# Standard Library -import typing as t - -# Third Party -import pytest - -# Project -from hyperglass.state import use_state -from hyperglass.models.api import Query -from hyperglass.configuration import init_ui_params -from hyperglass.models.directive import Directives -from hyperglass.models.config.params import Params -from hyperglass.models.config.devices import Devices - -# Local -from .._construct import Construct - -if t.TYPE_CHECKING: - # Project - from hyperglass.state import HyperglassState - - -@pytest.fixture -def params(): - return {} - - -@pytest.fixture -def devices(): - return [ - { - "name": "test1", - "address": "127.0.0.1", - "credential": {"username": "", "password": ""}, - "platform": "juniper", - "attrs": {"source4": "192.0.2.1", "source6": "2001:db8::1"}, - "directives": ["juniper_bgp_route"], - } - ] - - -@pytest.fixture -def directives(): - return [ - { - "juniper_bgp_route": { - "name": "BGP Route", - "field": {"description": "test"}, - } - } - ] - - -@pytest.fixture -def state( - *, - params: t.Dict[str, t.Any], - directives: t.Sequence[t.Dict[str, t.Any]], - devices: t.Sequence[t.Dict[str, t.Any]], -) -> t.Generator["HyperglassState", None, None]: - """Test fixture to initialize Redis store.""" - _state = use_state() - _params = Params(**params) - _directives = Directives.new(*directives) - - with _state.cache.pipeline() as pipeline: - # Write params and directives to the cache first to avoid a race condition where ui_params - # or devices try to access params or directives before they're available. - pipeline.set("params", _params) - pipeline.set("directives", _directives) - - _devices = Devices(*devices) - ui_params = init_ui_params(params=_params, devices=_devices) - - with _state.cache.pipeline() as pipeline: - pipeline.set("devices", _devices) - pipeline.set("ui_params", ui_params) - - yield _state - _state.clear() - - -def test_construct(state): - query = Query( - queryLocation="test1", - queryTarget="192.0.2.0/24", - queryType="juniper_bgp_route", - ) - constructor = Construct(device=state.devices["test1"], query=query) - assert constructor.target == "192.0.2.0/24" diff --git a/src/stale/hyperglass/hyperglass/execution/main.py b/src/stale/hyperglass/hyperglass/execution/main.py deleted file mode 100644 index 8f03406..0000000 --- a/src/stale/hyperglass/hyperglass/execution/main.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Execute validated & constructed query on device. - -This module is responsible for orchestrating the lifecycle of a query execution: -1. Mapping the device driver (SSH/Netmiko or HTTP). -2. Setting up execution timeouts. -3. Managing SSH proxies (jump hosts). -4. Collecting and parsing device output. -""" - -# Standard Library -import signal -from typing import TYPE_CHECKING, Any, Dict, Union, Callable - -# Project -from hyperglass.log import log -from hyperglass.state import use_state -from hyperglass.util.typing import is_series -from hyperglass.exceptions.public import DeviceTimeout, ResponseEmpty - -if TYPE_CHECKING: - from hyperglass.models.api import Query - from .drivers import Connection - from hyperglass.models.data import OutputDataModel - -# Local -from .drivers import HttpClient, NetmikoConnection - - -def map_driver(driver_name: str) -> "Connection": - """Map a driver string (from configuration) to the corresponding driver class.""" - - if driver_name == "hyperglass_http_client": - return HttpClient - - return NetmikoConnection - - -def handle_timeout(**exc_args: Any) -> Callable: - """Return a signal handler function that raises a DeviceTimeout. - Used with signal.SIGALRM to enforce execution deadlines. - """ - - def handler(*args: Any, **kwargs: Any) -> None: - raise DeviceTimeout(**exc_args) - - return handler - - -async def execute(query: "Query") -> Union["OutputDataModel", str]: - """Initiate query validation and execution against a remote device. - - Flow: - 1. Initialize driver based on device configuration. - 2. Set a POSIX alarm for the request timeout. - 3. Setup SSH proxy tunnel if required. - 4. Collect raw output from the device (via SSH or HTTP). - 5. Pass raw output through the driver's response parser (OutputPluginManager). - 6. Verify the response is not empty and return. - """ - params = use_state("params") - output = params.messages.general - _log = log.bind(query=query.summary(), device=query.device.id) - _log.debug("Initiating execution") - - # Resolve driver - mapped_driver = map_driver(query.device.driver) - driver: "Connection" = mapped_driver(query.device, query) - - # DEADLINE ENFORCEMENT: Set SIGALRM - signal.signal( - signal.SIGALRM, - handle_timeout(error=TimeoutError("Connection timed out"), device=query.device), - ) - signal.alarm(params.request_timeout - 1) - - # EXECUTION: Collect output - if query.device.proxy: - # Jump Host pattern: Open tunnel, then connect through it. - proxy = driver.setup_proxy() - with proxy() as tunnel: - response = await driver.collect(tunnel.local_bind_host, tunnel.local_bind_port) - else: - # Direct connection. - response = await driver.collect() - - # PARSING: Process raw output through plugins - output = await driver.response(response) - - # VALIDATION: Ensure output content exists - if is_series(output): - if len(output) == 0: - raise ResponseEmpty(query=query) - output = "\n\n".join(output) - - elif isinstance(output, str): - # If the output is a string (not structured) and is empty, - # produce an error. - if output == "" or output == "\n": - raise ResponseEmpty(query=query) - - elif isinstance(output, Dict): - # If the output an empty dict, responses have data, produce an - # error. - if not output: - raise ResponseEmpty(query=query) - - # RESET: Disable the alarm - signal.alarm(0) - - return output diff --git a/src/stale/hyperglass/hyperglass/external/__init__.py b/src/stale/hyperglass/hyperglass/external/__init__.py deleted file mode 100644 index 4d9d22e..0000000 --- a/src/stale/hyperglass/hyperglass/external/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Functions & handlers for external data.""" - -# Local -from .rpki import rpki_state -from .slack import SlackHook -from .generic import BaseExternal -from .msteams import MSTeams -from .bgptools import network_info, network_info_sync -from .webhooks import Webhook -from .http_client import HTTPClient - -__all__ = ( - "BaseExternal", - "HTTPClient", - "MSTeams", - "network_info_sync", - "network_info", - "rpki_state", - "SlackHook", - "Webhook", -) diff --git a/src/stale/hyperglass/hyperglass/external/_base.py b/src/stale/hyperglass/hyperglass/external/_base.py deleted file mode 100644 index 3954c51..0000000 --- a/src/stale/hyperglass/hyperglass/external/_base.py +++ /dev/null @@ -1,367 +0,0 @@ -"""Session handler for external http data sources.""" - -# Standard Library -import re -import json as _json -import socket -import typing as t -from json import JSONDecodeError -from socket import gaierror - -# Third Party -import httpx - -# Project -from hyperglass.log import log -from hyperglass.util import parse_exception, repr_from_attrs -from hyperglass.settings import Settings -from hyperglass.constants import __version__ -from hyperglass.models.fields import JsonValue, HttpMethod, Primitives -from hyperglass.exceptions.private import ExternalError - -if t.TYPE_CHECKING: - # Standard Library - from types import TracebackType - - # Project - from hyperglass.exceptions._common import ErrorLevel - from hyperglass.models.config.logging import Http - -D = t.TypeVar("D", bound=t.Dict) - - -def _prepare_dict(_dict: D) -> D: - return _json.loads(_json.dumps(_dict, default=str)) - - -class BaseExternal: - """Base session handler.""" - - def __init__( - self, - base_url: str, - config: t.Optional["Http"] = None, - uri_prefix: str = "", - uri_suffix: str = "", - verify_ssl: bool = True, - timeout: int = 10, - parse: bool = True, - ) -> None: - """Initialize connection instance.""" - self.__name__ = getattr(self, "name", "BaseExternal") - self.name = self.__name__ - self.config = config - self.base_url = base_url.strip("/") - self.uri_prefix = uri_prefix.strip("/") - self.uri_suffix = uri_suffix.strip("/") - self.verify_ssl = verify_ssl - self.timeout = timeout - self.parse = parse - - context = httpx.create_ssl_context(verify=verify_ssl) - - if Settings.ca_cert is not None: - context.load_verify_locations(cafile=str(Settings.ca_cert)) - - client_kwargs = { - "base_url": self.base_url, - "timeout": self.timeout, - "verify": context, - } - - self._session = httpx.Client(**client_kwargs) - self._asession = httpx.AsyncClient(**client_kwargs) - - @classmethod - def __init_subclass__( - cls: "BaseExternal", name: t.Optional[str] = None, **kwargs: t.Any - ) -> None: - """Set correct subclass name.""" - super().__init_subclass__(**kwargs) - cls.name = name or cls.__name__ - - async def __aenter__(self: "BaseExternal") -> "BaseExternal": - """Test connection on entry.""" - available = await self._atest() - - if available: - log.bind(url=self.base_url).debug("Initialized session") - return self - raise self._exception(f"Unable to create session to {self.name}") - - async def __aexit__( - self: "BaseExternal", - exc_type: t.Optional[t.Type[BaseException]] = None, - exc_value: t.Optional[BaseException] = None, - traceback: t.Optional["TracebackType"] = None, - ) -> True: - """Close connection on exit.""" - log.bind(url=self.base_url).debug("Closing session") - - if exc_type is not None: - log.error(str(exc_value)) - - await self._asession.aclose() - if exc_value is not None: - raise exc_value - return True - - def __enter__(self: "BaseExternal") -> "BaseExternal": - """Test connection on entry.""" - available = self._test() - - if available: - log.bind(url=self.base_url).debug("Initialized session") - return self - raise self._exception(f"Unable to create session to {self.name}") - - def __exit__( - self: "BaseExternal", - exc_type: t.Optional[t.Type[BaseException]] = None, - exc_value: t.Optional[BaseException] = None, - exc_traceback: t.Optional["TracebackType"] = None, - ) -> bool: - """Close connection on exit.""" - if exc_type is not None: - log.error(str(exc_value)) - self._session.close() - if exc_value is not None: - raise exc_value - return True - - def __repr__(self: "BaseExternal") -> str: - """Return user friendly representation of instance.""" - return repr_from_attrs(self, ("name", "base_url", "config", "parse")) - - def _exception( - self: "BaseExternal", - message: str, - exc: t.Optional[BaseException] = None, - level: "ErrorLevel" = "warning", - **kwargs: t.Any, - ) -> ExternalError: - """Add stringified exception to message if passed.""" - if exc is not None: - message = f"{message!s}: {exc!s}" - - return ExternalError(message=message, level=level, **kwargs) - - def _parse_response(self: "BaseExternal", response: httpx.Response) -> t.Any: - if self.parse: - parsed = {} - try: - parsed = response.json() - except JSONDecodeError: - try: - parsed = _json.loads(response) - except (JSONDecodeError, TypeError): - parsed = {"data": response.text} - else: - parsed = response - return parsed - - def _test(self: "BaseExternal") -> bool: - """Open a low-level connection to the base URL to ensure its port is open.""" - log.bind(url=self.base_url).debug("Testing connection") - - try: - # Parse out just the hostname from a URL string. - # E.g. `https://www.example.com` becomes `www.example.com` - test_host = re.sub(r"http(s)?\:\/\/", "", self.base_url) - - # Create a generic socket object - test_socket = socket.socket() - - # Try opening a low-level socket to make sure it's even - # listening on the port prior to trying to use it. - test_socket.connect((test_host, 443)) - - # Properly shutdown & close the socket. - test_socket.shutdown(1) - test_socket.close() - - except gaierror as err: - # Raised if the target isn't listening on the port - raise self._exception( - f"{self.name!r} appears to be unreachable at {self.base_url!r}", err - ) from None - - return True - - async def _atest(self: "BaseExternal") -> bool: - """Open a low-level connection to the base URL to ensure its port is open.""" - return self._test() - - def _build_request(self: "BaseExternal", **kwargs: t.Any) -> t.Dict[str, t.Any]: - """Process requests parameters into structure usable by http library.""" - # Standard Library - from operator import itemgetter - - supported_methods = ("GET", "POST", "PUT", "DELETE", "HEAD", "PATCH") - - ( - method, - endpoint, - item, - headers, - params, - data, - timeout, - response_required, - ) = itemgetter(*kwargs.keys())(kwargs) - - if method.upper() not in supported_methods: - raise self._exception( - f"Method must be one of {', '.join(supported_methods)}. Got: {str(method)}" - ) - - endpoint = "/".join( - i - for i in ( - "", - self.uri_prefix.strip("/"), - endpoint.strip("/"), - self.uri_suffix.strip("/"), - item, - ) - if i - ) - - request = { - "method": method, - "url": endpoint, - "headers": {"user-agent": f"hyperglass/{__version__}"}, - } - - if headers is not None: - request.update({"headers": headers}) - - if params is not None: - params = {str(k): str(v) for k, v in params.items() if v is not None} - request["params"] = params - - if data is not None: - if not isinstance(data, dict): - raise self._exception(f"Data must be a dict, got: {str(data)}") - request["json"] = _prepare_dict(data) - - if timeout is not None: - if not isinstance(timeout, int): - try: - timeout = int(timeout) - except TypeError as err: - raise self._exception(f"Timeout must be an int, got: {str(timeout)}") from err - request["timeout"] = timeout - return request - - async def _arequest( # noqa: C901 - self: "BaseExternal", - method: HttpMethod, - endpoint: str, - item: t.Union[str, int, None] = None, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - response_required: bool = False, - ) -> t.Any: - """Run HTTP POST operation.""" - request = self._build_request( - method=method, - endpoint=endpoint, - item=item, - headers=None, - params=params, - data=data, - timeout=timeout, - response_required=response_required, - ) - - try: - response = await self._asession.request(**request) - - if response.status_code not in range(200, 300): - status = httpx.codes(response.status_code) - error = self._parse_response(response) - raise self._exception( - f"{status.name.replace('_', ' ')}: {error}", level="danger" - ) from None - - except httpx.HTTPError as http_err: - raise self._exception(parse_exception(http_err), level="danger") from None - - return self._parse_response(response) - - async def _aget(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return await self._arequest(method="GET", endpoint=endpoint, **kwargs) - - async def _apost(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return await self._arequest(method="POST", endpoint=endpoint, **kwargs) - - async def _aput(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return await self._arequest(method="PUT", endpoint=endpoint, **kwargs) - - async def _adelete(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return await self._arequest(method="DELETE", endpoint=endpoint, **kwargs) - - async def _apatch(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return await self._arequest(method="PATCH", endpoint=endpoint, **kwargs) - - async def _ahead(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return await self._arequest(method="HEAD", endpoint=endpoint, **kwargs) - - def _request( # noqa: C901 - self: "BaseExternal", - method: HttpMethod, - endpoint: str, - item: t.Union[str, int, None] = None, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - response_required: bool = False, - ) -> t.Any: - """Run HTTP POST operation.""" - request = self._build_request( - method=method, - endpoint=endpoint, - item=item, - headers=None, - params=params, - data=data, - timeout=timeout, - response_required=response_required, - ) - - try: - response = self._session.request(**request) - - if response.status_code not in range(200, 300): - status = httpx.codes(response.status_code) - error = self._parse_response(response) - raise self._exception( - f"{status.name.replace('_', ' ')}: {error}", level="danger" - ) from None - - except httpx.HTTPError as http_err: - raise self._exception(parse_exception(http_err), level="danger") from None - - return self._parse_response(response) - - def _get(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return self._request(method="GET", endpoint=endpoint, **kwargs) - - def _post(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return self._request(method="POST", endpoint=endpoint, **kwargs) - - def _put(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return self._request(method="PUT", endpoint=endpoint, **kwargs) - - def _delete(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return self._request(method="DELETE", endpoint=endpoint, **kwargs) - - def _patch(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return self._request(method="PATCH", endpoint=endpoint, **kwargs) - - def _head(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: - return self._request(method="HEAD", endpoint=endpoint, **kwargs) diff --git a/src/stale/hyperglass/hyperglass/external/bgptools.py b/src/stale/hyperglass/hyperglass/external/bgptools.py deleted file mode 100644 index f2fd7f6..0000000 --- a/src/stale/hyperglass/hyperglass/external/bgptools.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Query & parse data from bgp.tools. - -- See https://bgp.tools/credits for acknowledgements and licensing. -- See https://bgp.tools/kb/api for query documentation. -""" - -# Standard Library -import re -import typing as t -import asyncio -from ipaddress import IPv4Address, IPv6Address, ip_address - -# Project -from hyperglass.log import log -from hyperglass.state import use_state - -DEFAULT_KEYS = ("asn", "ip", "prefix", "country", "rir", "allocated", "org") - -CACHE_KEY = "hyperglass.external.bgptools" - -TargetDetail = t.TypedDict( - "TargetDetail", - {"asn": str, "ip": str, "country": str, "rir": str, "allocated": str, "org": str}, -) - -TargetData = t.Dict[str, TargetDetail] - - -def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.Tuple[str, ...]]: - """Construct a mapping of default data and other data that should be queried. - - Targets in the mapping don't need to be queried and already have default values. Targets in the - query tuple should be queried. - """ - default_data = {} - query = () - for target in targets: - detail: TargetDetail = dict.fromkeys(DEFAULT_KEYS, "None") - try: - valid: t.Union[IPv4Address, IPv6Address] = ip_address(target) - - checks = ( - (valid.version == 6 and valid.is_site_local, "Site Local Address"), - (valid.is_loopback, "Loopback Address"), - (valid.is_multicast, "Multicast Address"), - (valid.is_link_local, "Link Local Address"), - (valid.is_private, "Private Address"), - ) - for exp, rir in checks: - if exp is True: - detail["rir"] = rir - break - - should_query = any((valid.is_global, valid.is_unspecified, valid.is_reserved)) - - if not should_query: - detail["ip"] = str(target) - default_data[str(target)] = detail - elif should_query: - query += (str(target),) - - except ValueError: - pass - - return default_data, query - - -def parse_whois(output: str, targets: t.List[str]) -> TargetDetail: - """Parse raw whois output from bgp.tools. - - Sample output: - AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name - 13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc. - """ - - def lines(raw): - """Generate clean string values for each column.""" - for r in (r for r in raw.split("\n") if r): - fields = (re.sub(r"(\n|\r)", "", field).strip(" ") for field in r.split("|")) - yield fields - - data = {} - - for line in lines(output): - # Unpack each line's parsed values. - asn, ip, prefix, country, rir, allocated, org = line - - # Match the line to the item in the list of resources to query. - if ip in targets: - i = targets.index(ip) - data[targets[i]] = { - "asn": asn, - "ip": ip, - "prefix": prefix, - "country": country, - "rir": rir, - "allocated": allocated, - "org": org, - } - log.bind(data=data).debug("Parsed bgp.tools data") - return data - - -async def run_whois(targets: t.List[str]) -> str: - """Open raw socket to bgp.tools and execute query.""" - - # Construct bulk query - query = "\n".join(("begin", *targets, "end\n")).encode() - - # Open the socket to bgp.tools - log.debug("Opening connection to bgp.tools") - reader, writer = await asyncio.open_connection("bgp.tools", port=43) - - # Send the query - writer.write(query) - if writer.can_write_eof(): - writer.write_eof() - await writer.drain() - - # Read the response - response = b"" - while True: - data = await reader.read(128) - if data: - response += data - else: - log.debug("Closing connection to bgp.tools") - writer.close() - break - - return response.decode() - - -async def network_info(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - - default_data, query_targets = default_ip_targets(*targets) - - cache = use_state("cache") - - # Set default data structure. - 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 {} - - # Try to use cached data for each of the items in the list of - # resources. - for target in (target for target in query_targets if target in cached): - # Reassign the cached network info to the matching resource. - query_data[target] = cached[target] - log.bind(target=target).debug("Using cached network info") - - # Remove cached items from the resource list so they're not queried. - targets = [t for t in query_targets if t not in cached] - - try: - if targets: - whoisdata = await run_whois(targets) - - if whoisdata: - # If the response is not empty, parse it. - query_data.update(parse_whois(whoisdata, targets)) - - # Cache the response - for target in targets: - cache.set_map_item(CACHE_KEY, target, query_data[target]) - log.bind(target=t).debug("Cached network info") - - except Exception as err: - log.error(err) - - return {**default_data, **query_data} - - -def network_info_sync(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - return asyncio.run(network_info(*targets)) diff --git a/src/stale/hyperglass/hyperglass/external/generic.py b/src/stale/hyperglass/hyperglass/external/generic.py deleted file mode 100644 index b36cc91..0000000 --- a/src/stale/hyperglass/hyperglass/external/generic.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Session handler for Generic HTTP API endpoint.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.log import log -from hyperglass.models.webhook import Webhook - -# Local -from ._base import BaseExternal - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.config.logging import Http - - -class GenericHook(BaseExternal, name="Generic"): - """Slack session handler.""" - - def __init__(self: "GenericHook", config: "Http") -> None: - """Initialize external base class with http connection details.""" - - super().__init__(base_url=f"{config.host.scheme}://{config.host.host}", config=config) - - async def send(self: "GenericHook", query: t.Dict[str, t.Any]): - """Send an incoming webhook to http endpoint.""" - - payload = Webhook(**query) - log.bind(host=self.config.host.host, payload=payload).debug("Sending request") - - return await self._apost( - endpoint=self.config.host.path, - headers=self.config.headers, - params=self.config.params, - data=payload.export_dict(), - ) diff --git a/src/stale/hyperglass/hyperglass/external/http_client.py b/src/stale/hyperglass/hyperglass/external/http_client.py deleted file mode 100644 index dc968c4..0000000 --- a/src/stale/hyperglass/hyperglass/external/http_client.py +++ /dev/null @@ -1,234 +0,0 @@ -"""HTTP Client for plugin use.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.models.fields import JsonValue, Primitives - -# Local -from ._base import BaseExternal - - -class HTTPClient(BaseExternal, name="HTTPClient"): - """Wrapper around a standard HTTP Client.""" - - def __init__(self: "HTTPClient", base_url: str, timeout: int = 10) -> None: - """Create an HTTPClient instance.""" - super().__init__(base_url=base_url, timeout=timeout, parse=False) - - async def aget( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an async HTTP GET request.""" - return await self._arequest( - method="GET", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - async def apost( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an async HTTP POST request.""" - return await self._arequest( - method="POST", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - async def aput( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an async HTTP PUT request.""" - return await self._arequest( - method="PUT", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - async def adelete( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an async HTTP DELETE request.""" - return await self._arequest( - method="DELETE", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - async def apatch( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an async HTTP PATCH request.""" - return await self._arequest( - method="PATCH", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - async def ahead( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an async HTTP HEAD request.""" - return await self._arequest( - method="HEAD", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - def get( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an HTTP GET request.""" - return self._request( - method="GET", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - def post( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an HTTP POST request.""" - return self._request( - method="POST", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - def put( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an HTTP PUT request.""" - return self._request( - method="PUT", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - def delete( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an HTTP DELETE request.""" - return self._request( - method="DELETE", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - def patch( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an HTTP PATCH request.""" - return self._request( - method="PATCH", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) - - def head( - self: "HTTPClient", - endpoint: str, - headers: t.Dict[str, str] = None, - params: t.Dict[str, JsonValue[Primitives]] = None, - data: t.Optional[t.Any] = None, - timeout: t.Optional[int] = None, - ) -> t.Any: - """Perform an HTTP HEAD request.""" - return self._request( - method="HEAD", - endpoint=endpoint, - headers=headers, - params=params, - data=data, - timeout=timeout, - ) diff --git a/src/stale/hyperglass/hyperglass/external/msteams.py b/src/stale/hyperglass/hyperglass/external/msteams.py deleted file mode 100644 index 576cdf1..0000000 --- a/src/stale/hyperglass/hyperglass/external/msteams.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Session handler for Microsoft Teams API.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.log import log -from hyperglass.external._base import BaseExternal -from hyperglass.models.webhook import Webhook - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.config.logging import Http - - -class MSTeams(BaseExternal, name="MSTeams"): - """Microsoft Teams session handler.""" - - def __init__(self: "MSTeams", config: "Http") -> None: - """Initialize external base class with Microsoft Teams connection details.""" - - super().__init__(base_url="https://outlook.office.com", config=config, parse=False) - - async def send(self: "MSTeams", query: t.Dict[str, t.Any]): - """Send an incoming webhook to Microsoft Teams.""" - - payload = Webhook(**query) - log.bind(destination="MS Teams", payload=payload).debug("Sending request") - - return await self._apost(endpoint=self.config.host.path, data=payload.msteams()) diff --git a/src/stale/hyperglass/hyperglass/external/rpki.py b/src/stale/hyperglass/hyperglass/external/rpki.py deleted file mode 100644 index 139ccf8..0000000 --- a/src/stale/hyperglass/hyperglass/external/rpki.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Validate RPKI state via Cloudflare GraphQL API.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.log import log -from hyperglass.state import use_state -from hyperglass.external._base import BaseExternal - -if t.TYPE_CHECKING: - # Standard Library - from ipaddress import IPv4Address, IPv6Address - -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} -CACHE_KEY = "hyperglass.external.rpki" - - -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: - """Get RPKI state and map to expected integer.""" - _log = log.bind(prefix=prefix, asn=asn) - _log.debug("Validating RPKI State") - - cache = use_state("cache") - - state = 3 - ro = f"{prefix!s}@{asn!s}" - - cached = cache.get_map(CACHE_KEY, ro) - - if cached is not None: - state = cached - else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") - try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: - validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 - - state = RPKI_STATE_MAP[validation_state] - cache.set_map_item(CACHE_KEY, ro, state) - except Exception as err: - log.error(err) - # Don't cache the state when an error produced it. - state = 3 - - msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) - if cached is not None: - msg += " [CACHED]" - - log.debug(msg) - return state diff --git a/src/stale/hyperglass/hyperglass/external/slack.py b/src/stale/hyperglass/hyperglass/external/slack.py deleted file mode 100644 index 793663f..0000000 --- a/src/stale/hyperglass/hyperglass/external/slack.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Session handler for Slack API.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.log import log -from hyperglass.external._base import BaseExternal -from hyperglass.models.webhook import Webhook - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.config.logging import Http - - -class SlackHook(BaseExternal, name="Slack"): - """Slack session handler.""" - - def __init__(self: "SlackHook", config: "Http") -> None: - """Initialize external base class with Slack connection details.""" - - super().__init__(base_url="https://hooks.slack.com", config=config, parse=False) - - async def send(self: "SlackHook", query: t.Dict[str, t.Any]): - """Send an incoming webhook to Slack.""" - - payload = Webhook(**query) - log.bind(destination="Slack", payload=payload).debug("Sending request") - - return await self._apost(endpoint=self.config.host.path, data=payload.slack()) diff --git a/src/stale/hyperglass/hyperglass/external/tests/__init__.py b/src/stale/hyperglass/hyperglass/external/tests/__init__.py deleted file mode 100644 index 4d703de..0000000 --- a/src/stale/hyperglass/hyperglass/external/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""External data testing.""" diff --git a/src/stale/hyperglass/hyperglass/external/tests/test_base.py b/src/stale/hyperglass/hyperglass/external/tests/test_base.py deleted file mode 100644 index 6604f4e..0000000 --- a/src/stale/hyperglass/hyperglass/external/tests/test_base.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Test external http client.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Project -from hyperglass.exceptions.private import ExternalError -from hyperglass.models.config.logging import Http - -# Local -from .._base import BaseExternal - -config = Http(provider="generic", host="https://httpbin.org") - - -def test_base_external_sync(): - with BaseExternal(base_url="https://httpbin.org", config=config) as client: - res1 = client._get("/get") - res2 = client._get("/get", params={"key": "value"}) - res3 = client._post("/post", data={"strkey": "value", "intkey": 1}) - assert res1["url"] == "https://httpbin.org/get" - assert res2["args"].get("key") == "value" - assert res3["json"].get("strkey") == "value" - assert res3["json"].get("intkey") == 1 - - with pytest.raises(ExternalError): - with BaseExternal(base_url="https://httpbin.org", config=config, timeout=2) as client: - client._get("/delay/4") - - -async def _run_test_base_external_async(): - async with BaseExternal(base_url="https://httpbin.org", config=config) as client: - res1 = await client._aget("/get") - res2 = await client._aget("/get", params={"key": "value"}) - res3 = await client._apost("/post", data={"strkey": "value", "intkey": 1}) - assert res1["url"] == "https://httpbin.org/get" - assert res2["args"].get("key") == "value" - assert res3["json"].get("strkey") == "value" - assert res3["json"].get("intkey") == 1 - - with pytest.raises(ExternalError): - async with BaseExternal(base_url="https://httpbin.org", config=config, timeout=2) as client: - await client._get("/delay/4") - - -def test_base_external_async(): - asyncio.run(_run_test_base_external_async()) diff --git a/src/stale/hyperglass/hyperglass/external/tests/test_bgptools.py b/src/stale/hyperglass/hyperglass/external/tests/test_bgptools.py deleted file mode 100644 index 542c1dc..0000000 --- a/src/stale/hyperglass/hyperglass/external/tests/test_bgptools.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test bgp.tools interactions.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Local -from ..bgptools import run_whois, parse_whois, network_info - -WHOIS_OUTPUT = """AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name -13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc.""" - - -# 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"}), - ("fe80:dead:beef::1", {"asn": "None", "rir": "Link Local Address"}), - ("2001:db8::1", {"asn": "None", "rir": "Private Address"}), - ("1.1.1.1", {"asn": "13335", "rir": "ARIN"}), - ) - for addr, fields in checks: - info = asyncio.run(network_info(addr)) - assert addr in info - for key, expected in fields.items(): - assert info[addr][key] == expected - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_whois(): - addr = "192.0.2.1" - response = asyncio.run(run_whois([addr])) - assert isinstance(response, str) - assert response != "" - - -def test_whois_parser(): - addr = "1.1.1.1" - result = parse_whois(WHOIS_OUTPUT, [addr]) - assert isinstance(result, dict) - assert addr in result, "Address missing" - assert result[addr]["asn"] == "13335" - assert result[addr]["rir"] == "ARIN" - assert result[addr]["org"] == "Cloudflare, Inc." diff --git a/src/stale/hyperglass/hyperglass/external/tests/test_rpki.py b/src/stale/hyperglass/hyperglass/external/tests/test_rpki.py deleted file mode 100644 index 01438e7..0000000 --- a/src/stale/hyperglass/hyperglass/external/tests/test_rpki.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Test RPKI data fetching.""" - -# Third Party -import pytest - -# Local -from ..rpki import RPKI_NAME_MAP, rpki_state - -TEST_STATES = ( - ("103.21.244.0/24", 13335, 0), - ("1.1.1.0/24", 13335, 1), - ("192.0.2.0/24", 65000, 2), -) - - -@pytest.mark.dependency() -def test_rpki(): - for prefix, asn, expected in TEST_STATES: - 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 - ) - ) diff --git a/src/stale/hyperglass/hyperglass/external/webhooks.py b/src/stale/hyperglass/hyperglass/external/webhooks.py deleted file mode 100644 index 41ffb57..0000000 --- a/src/stale/hyperglass/hyperglass/external/webhooks.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Convenience functions for webhooks.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.exceptions.private import UnsupportedError - -# Local -from ._base import BaseExternal -from .slack import SlackHook -from .generic import GenericHook -from .msteams import MSTeams - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.config.logging import Http - -PROVIDER_MAP = { - "generic": GenericHook, - "msteams": MSTeams, - "slack": SlackHook, -} - - -class Webhook(BaseExternal): - """Get webhook for provider name.""" - - def __new__(cls: "Webhook", config: "Http") -> "BaseExternal": - """Return instance for correct provider handler.""" - try: - provider_class = PROVIDER_MAP[config.provider] - return provider_class(config) - except KeyError as err: - raise UnsupportedError( - message="{p} is not yet supported as a webhook target.", - p=config.provider.title(), - ) from err diff --git a/src/stale/hyperglass/hyperglass/frontend/__init__.py b/src/stale/hyperglass/hyperglass/frontend/__init__.py deleted file mode 100644 index bbd7ace..0000000 --- a/src/stale/hyperglass/hyperglass/frontend/__init__.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Utility functions for frontend-related tasks.""" - -# Standard Library -import os -import json -import math -import shutil -import typing as t -import asyncio -from pathlib import Path - -# Project -from hyperglass.log import log -from hyperglass.util import copyfiles, check_path, move_files, dotenv_to_dict - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.ui import UIParameters - - -def get_ui_build_timeout() -> t.Optional[int]: - """Read the UI build timeout from environment variables or set a default.""" - timeout = None - - if "HYPERGLASS_UI_BUILD_TIMEOUT" in os.environ: - timeout = int(os.environ["HYPERGLASS_UI_BUILD_TIMEOUT"]) - log.bind(timeout=timeout).debug("Found UI build timeout environment variable") - - return timeout - - -async def check_node_modules() -> bool: - """Check if node_modules exists and has contents.""" - - ui_path = Path(__file__).parent.parent / "ui" - node_modules = ui_path / "node_modules" - - exists = node_modules.exists() - valid = exists - - if exists and not tuple(node_modules.iterdir()): - valid = False - - return valid - - -async def read_package_json() -> t.Dict[str, t.Any]: - """Import package.json as a python dict.""" - - package_json_file = Path(__file__).parent.parent / "ui" / "package.json" - - try: - with package_json_file.open("r") as file: - package_json = json.load(file) - - except Exception as err: - raise RuntimeError(f"Error reading package.json: {str(err)}") from err - - return package_json - - -async def node_initial(timeout: int = 180, dev_mode: bool = False) -> str: - """Initialize node_modules.""" - - ui_path = Path(__file__).parent.parent / "ui" - - env_timeout = get_ui_build_timeout() - - if env_timeout is not None and env_timeout > timeout: - timeout = env_timeout - - proc = await asyncio.create_subprocess_shell( - cmd="pnpm install", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=ui_path, - ) - - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - messages = stdout.decode("utf-8").strip() - errors = stderr.decode("utf-8").strip() - - if proc.returncode != 0: - raise RuntimeError(f"\nMessages:\n{messages}\nErrors:\n{errors}") - - await proc.wait() - - return "\n".join(messages) - - -async def build_ui(app_path: Path): - """Execute `next build` & `next export` from UI directory. - - ### Raises - RuntimeError: Raised if exit code is not 0. - RuntimeError: Raised when any other error occurs. - """ - timeout = get_ui_build_timeout() - - ui_dir = Path(__file__).parent.parent / "ui" - build_dir = app_path / "static" / "ui" - out_dir = ui_dir / "out" - - build_command = "node_modules/.bin/next build" - - all_messages = [] - try: - proc = await asyncio.create_subprocess_shell( - cmd=build_command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=ui_dir, - ) - - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - messages = stdout.decode("utf-8").strip() - errors = stderr.decode("utf-8").strip() - - if proc.returncode != 0: - raise RuntimeError(f"\nMessages:\n{messages}\nErrors:\n{errors}") - - await proc.wait() - all_messages.append(messages) - - except asyncio.TimeoutError as err: - raise RuntimeError(f"{timeout} second timeout exceeded while building UI") from err - - except Exception as err: - log.error(err) - raise RuntimeError(str(err)) from err - - if build_dir.exists(): - shutil.rmtree(build_dir) - shutil.copytree(src=out_dir, dst=build_dir, dirs_exist_ok=False) - log.bind(src=out_dir, dst=build_dir).debug("Migrated Next.JS build output") - - return "\n".join(all_messages) - - -def generate_opengraph( - image_path: Path, - max_width: int, - max_height: int, - target_path: Path, - background_color: str, -): - """Generate an OpenGraph compliant image.""" - # Third Party - from PIL import Image - - def center_point(background: Image, foreground: Image): - """Generate a tuple of center points for PIL.""" - bg_x, bg_y = background.size[0:2] - fg_x, fg_y = foreground.size[0:2] - x1 = math.floor((bg_x / 2) - (fg_x / 2)) - y1 = math.floor((bg_y / 2) - (fg_y / 2)) - x2 = math.floor((bg_x / 2) + (fg_x / 2)) - y2 = math.floor((bg_y / 2) + (fg_y / 2)) - return (x1, y1, x2, y2) - - # Convert image to JPEG format with static name "opengraph.jpg" - dst_path = target_path / "opengraph.jpg" - - # Copy the original image to the target path - copied = shutil.copy2(image_path, target_path) - log.bind(source=str(image_path), destination=str(target_path)).debug("Copied OpenGraph image") - - with Image.open(copied) as src: - # Only resize the image if it needs to be resized - if src.size[0] != max_width or src.size[1] != max_height: - # Resize image while maintaining aspect ratio - log.debug("Opengraph image is not 1200x630, resizing...") - src.thumbnail((max_width, max_height)) - - # Only impose a background image if the original image has - # alpha/transparency channels - if src.mode in ("RGBA", "LA"): - log.debug("Opengraph image has transparency, converting...") - background = Image.new("RGB", (max_width, max_height), background_color) - background.paste(src, box=center_point(background, src)) - dst = background - else: - dst = src - - # Save new image to derived target path - dst.save(dst_path) - - # Delete the copied image - Path(copied).unlink() - - if not dst_path.exists(): - raise RuntimeError(f"Unable to save resized image to {str(dst_path)}") - log.bind(path=str(dst_path)).debug("OpenGraph image ready") - - return True - - -def migrate_images(app_path: Path, params: "UIParameters"): - """Migrate images from source code to install directory.""" - images_dir = app_path / "static" / "images" - favicon_dir = images_dir / "favicons" - check_path(favicon_dir, create=True) - src_files = () - dst_files = () - - for image in ("light", "dark", "favicon"): - src: Path = getattr(params.web.logo, image) - dst = images_dir / f"{image + src.suffix}" - src_files += (src,) - dst_files += (dst,) - return copyfiles(src_files, dst_files) - - -def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None: - """Create a TypeScript file in the `ui` directory containing favicon formats. - - This file should stay the same, unless the favicons library updates - supported formats. - """ - # Standard Library - from collections import OrderedDict - - file = Path(__file__).parent.parent / "ui" / "favicon-formats.ts" - - # Sort each favicon definition to ensure the result stays the same - # time the UI build runs. - ordered = json.dumps([OrderedDict(sorted(fmt.items())) for fmt in formats]) - data = "import type {{ Favicon }} from '~/types';export default {} as Favicon[];".format( - ordered - ) - file.write_text(data) - - -def write_custom_files(params: "UIParameters") -> None: - """Write custom files to the `ui` directory so they can be imported and rendered.""" - js = Path(__file__).parent.parent / "ui" / "custom.js" - html = Path(__file__).parent.parent / "ui" / "custom.html" - - # Handle Custom JS. - if params.web.custom_javascript is not None: - copyfiles((params.web.custom_javascript,), (js,)) - else: - with js.open("w") as f: - f.write("") - # Handle Custom HTML. - if params.web.custom_html is not None: - copyfiles((params.web.custom_html,), (html,)) - else: - with html.open("w") as f: - f.write("") - - -async def build_frontend( # noqa: C901 - dev_mode: bool, - dev_url: str, - prod_url: str, - params: "UIParameters", - app_path: Path, - force: bool = False, - timeout: int = 180, - full: bool = False, -) -> bool: - """Perform full frontend UI build process.""" - # Standard Library - import hashlib - - # Third Party - from favicons import Favicons # type:ignore - - # Project - from hyperglass.constants import __version__ - - # Create temporary file. json file extension is added for easy - # webpack JSON parsing. - dot_env_file = Path(__file__).parent.parent / "ui" / ".env" - env_config = {} - - ui_config_file = Path(__file__).parent.parent / "ui" / "hyperglass.json" - - ui_config_file.write_text(params.export_json(by_alias=True)) - - package_json = await read_package_json() - - # Set NextJS production/development mode and base URL based on - # developer_mode setting. - if dev_mode: - env_config.update({"HYPERGLASS_URL": dev_url, "NODE_ENV": "development"}) - - else: - env_config.update({"HYPERGLASS_URL": prod_url, "NODE_ENV": "production"}) - - # Check if hyperglass/ui/node_modules has been initialized. If not, - # initialize it. - initialized = await check_node_modules() - - if initialized: - log.debug("node_modules is already initialized") - - elif not initialized: - log.debug("node_modules has not been initialized. Starting initialization...") - - node_setup = await node_initial(timeout, dev_mode) - - if node_setup == "": - log.debug("Re-initialized node_modules") - - images_dir = app_path / "static" / "images" - favicon_dir = images_dir / "favicons" - - if not favicon_dir.exists(): - favicon_dir.mkdir() - - async with Favicons( - source=params.web.logo.favicon, - output_directory=favicon_dir, - base_url="/images/favicons/", - ) as favicons: - await favicons.generate() - log.bind(count=favicons.completed).debug("Generated favicons") - write_favicon_formats(favicons.formats()) - - build_data = { - "params": params.export_dict(), - "version": __version__, - "package_json": package_json, - } - - build_json = json.dumps(build_data, default=str) - - # Create SHA256 hash from all parameters passed to UI, use as - # build identifier. - build_id = hashlib.sha256(build_json.encode()).hexdigest() - - # Read hard-coded environment file from last build. If build ID - # matches this build's ID, don't run a new build. - if dot_env_file.exists() and not force: - env_data = dotenv_to_dict(dot_env_file) - env_build_id = env_data.get("HYPERGLASS_BUILD_ID", "None") - log.bind(id=env_build_id).debug("Previous build detected") - - if env_build_id == build_id: - log.debug("UI parameters unchanged since last build, skipping UI build...") - return True - - env_config.update({"HYPERGLASS_BUILD_ID": build_id}) - - dot_env_file.write_text("\n".join(f"{k}={v}" for k, v in env_config.items())) - log.bind(path=str(dot_env_file)).debug("Wrote UI environment file") - - # Initiate Next.JS export process. - if any((not dev_mode, force, full)): - log.info("Starting UI build") - initialize_result = await node_initial(timeout, dev_mode) - build_result = await build_ui(app_path=app_path) - - if initialize_result: - log.debug(initialize_result) - elif initialize_result == "": - log.debug("Re-initialized node_modules") - - if build_result: - log.info("Completed UI build") - elif dev_mode and not force: - log.debug("Running in developer mode, did not build new UI files") - - migrate_images(app_path, params) - - write_custom_files(params) - - generate_opengraph( - params.web.opengraph.image, - 1200, - 630, - images_dir, - params.web.theme.colors.black, - ) - - return True diff --git a/src/stale/hyperglass/hyperglass/log.py b/src/stale/hyperglass/hyperglass/log.py deleted file mode 100644 index aedf309..0000000 --- a/src/stale/hyperglass/hyperglass/log.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Logging instance setup & configuration.""" - -# Standard Library -import sys -import typing as t -import logging -from datetime import datetime - -# Third Party -from loguru import logger as _loguru_logger -from rich.theme import Theme -from rich.console import Console -from rich.logging import RichHandler - -# Local -from .util import dict_to_kwargs -from .constants import __version__ - -if t.TYPE_CHECKING: - # Standard Library - from pathlib import Path - - # Third Party - from loguru import Logger as Record - from pydantic import ByteSize - - # Project - from hyperglass.models.fields import LogFormat - -_FMT_DEBUG = ( - "[{level}] {time:YYYYMMDD} {time:HH:mm:ss} |" - "{line} | {function} {message} {extra}" -) - -_FMT = "[{level}] {time:YYYYMMDD} {time:HH:mm:ss} | {message} {extra}" - -_FMT_FILE = "[{time:YYYYMMDD} {time:HH:mm:ss}] {message} {extra}" -_FMT_BASIC = "{message} {extra}" -_LOG_LEVELS = [ - {"name": "TRACE", "color": ""}, - {"name": "DEBUG", "color": ""}, - {"name": "INFO", "color": ""}, - {"name": "SUCCESS", "color": ""}, - {"name": "WARNING", "color": ""}, - {"name": "ERROR", "color": ""}, - {"name": "CRITICAL", "color": ""}, -] - -_EXCLUDE_MODULES = ( - "PIL", - "svglib", - "paramiko.transport", -) - -HyperglassConsole = Console( - theme=Theme( - { - "info": "bold cyan", - "warning": "bold yellow", - "error": "bold red", - "success": "bold green", - "critical": "bold bright_red", - "logging.level.info": "bold cyan", - "logging.level.warning": "bold yellow", - "logging.level.error": "bold red", - "logging.level.critical": "bold bright_red", - "logging.level.success": "bold green", - "subtle": "rgb(128,128,128)", - } - ) -) - -log = _loguru_logger - - -def formatter(record: "Record") -> str: - """Format log messages with extra data as kwargs string.""" - msg = record.get("message", "") - extra = record.get("extra", {}) - extra_str = dict_to_kwargs(extra) - return " ".join((msg, extra_str)) - - -def filter_uvicorn_values(record: "Record") -> bool: - """Drop noisy uvicorn messages.""" - drop = ( - "Application startup", - "Application shutdown", - "Finished server process", - "Shutting down", - "Waiting for application", - "Started server process", - "Started parent process", - "Stopping parent process", - ) - for match in drop: - if match in record["message"]: - return False - return True - - -class LibInterceptHandler(logging.Handler): - """Custom log handler for integrating third party library logging with hyperglass's logger.""" - - def emit(self, record): - """Emit log record. - - See: https://github.com/Delgan/loguru (Readme) - """ - # Get corresponding Loguru level if it exists - try: - level = _loguru_logger.level(record.levelname).name - except ValueError: - level = record.levelno - - # Find caller from where originated the logged message - frame, depth = logging.currentframe(), 2 - while frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - _loguru_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def init_logger(level: t.Union[int, str] = logging.INFO): - """Initialize hyperglass logging instance.""" - - for mod in _EXCLUDE_MODULES: - logging.getLogger(mod).propagate = False - - # Reset built-in Loguru configurations. - _loguru_logger.remove() - - if sys.stdout.isatty(): - # Use Rich for logging if hyperglass started from a TTY. - - _loguru_logger.add( - sink=RichHandler( - console=HyperglassConsole, - rich_tracebacks=True, - tracebacks_show_locals=level == logging.DEBUG, - log_time_format="[%Y%m%d %H:%M:%S]", - ), - format=formatter, - colorize=False, - level=level, - filter=filter_uvicorn_values, - enqueue=True, - ) - else: - # Otherwise, use regular format. - _loguru_logger.add( - sink=sys.stdout, - enqueue=True, - format=_FMT if level == logging.INFO else _FMT_DEBUG, - level=level, - colorize=False, - filter=filter_uvicorn_values, - ) - - _loguru_logger.configure(levels=_LOG_LEVELS) - - return _loguru_logger - - -def enable_file_logging( - *, - directory: "Path", - log_format: "LogFormat", - max_size: "ByteSize", - level: t.Union[str, int], -) -> None: - """Set up file-based logging from configuration parameters.""" - - if log_format == "json": - log_file_name = "hyperglass.log.json" - structured = True - else: - log_file_name = "hyperglass.log" - structured = False - - log_file = directory / log_file_name - - if log_format == "text": - now_str = datetime.utcnow().strftime("%B %d, %Y beginning at %H:%M:%S UTC") - header_lines = ( - f"# {line}" - for line in ( - f"hyperglass {__version__}", - f"Logs for {now_str}", - f"Log Level: {'INFO' if level == logging.INFO else 'DEBUG'}", - ) - ) - header = "\n" + "\n".join(header_lines) + "\n" - - with log_file.open("a+") as lf: - lf.write(header) - - _loguru_logger.add( - enqueue=True, - sink=log_file, - format=_FMT_FILE, - serialize=structured, - level=level, - encoding="utf8", - colorize=False, - rotation=max_size.human_readable(), - ) - _loguru_logger.bind(path=log_file).debug("Logging to file") - - -def enable_syslog_logging(*, host: str, port: int) -> None: - """Set up syslog logging from configuration parameters.""" - - # Standard Library - from logging.handlers import SysLogHandler - - _loguru_logger.add( - SysLogHandler(address=(str(host), port)), - format=_FMT_BASIC, - enqueue=True, - colorize=False, - ) - _loguru_logger.bind(host=host, port=port).debug("Logging to syslog target") diff --git a/src/stale/hyperglass/hyperglass/main.py b/src/stale/hyperglass/hyperglass/main.py deleted file mode 100644 index 9e40fb1..0000000 --- a/src/stale/hyperglass/hyperglass/main.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Start hyperglass. - -This is the main entry point for the hyperglass application. It handles -pre-start checks (Python/Node versions), builds the frontend, registers -plugins, and starts the Uvicorn ASGI server. -""" - -# Standard Library -import sys -import typing as t -import asyncio -import logging - -# Third Party -import uvicorn - -# Local -from .log import LibInterceptHandler, init_logger, enable_file_logging, enable_syslog_logging -from .util import get_node_version -from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__ - -# COMPATIBILITY CHECK: Python -# Ensure the Python version meets the minimum requirements defined in constants.py. -pretty_version = ".".join(tuple(str(v) for v in MIN_PYTHON_VERSION)) -if sys.version_info < MIN_PYTHON_VERSION: - raise RuntimeError(f"Python {pretty_version}+ is required.") - -# COMPATIBILITY CHECK: NodeJS -# Ensure the NodeJS version meets the minimum requirements. -node_major, node_minor, node_patch = get_node_version() - -if node_major < MIN_NODE_VERSION: - installed = ".".join(str(v) for v in (node_major, node_minor, node_patch)) - raise RuntimeError(f"NodeJS {MIN_NODE_VERSION!s}+ is required (version {installed} installed)") - - -# Local imports for state and settings -from .util import cpu_count -from .state import use_state -from .settings import Settings - -# GLOBAL LOGGING SETUP -# Set log level based on debug settings and initialize the intercept handler -# for third-party libraries (Paramiko, Scrapy, etc.). -LOG_LEVEL = logging.INFO if Settings.debug is False else logging.DEBUG -logging.basicConfig(handlers=[LibInterceptHandler()], level=0, force=True) -log = init_logger(LOG_LEVEL) - - -async def build_ui() -> bool: - """Perform a UI build prior to starting the application. - - This compiles the Next.js/Tailwind frontend located in src/hyperglass/docs - (or as configured) and moves the assets to the static directory. - """ - # Local - from .frontend import build_frontend - - state = use_state() - await build_frontend( - dev_mode=Settings.dev_mode, - dev_url=Settings.dev_url, - prod_url=Settings.prod_url, - params=state.ui_params, - app_path=Settings.app_path, - ) - return True - - -def register_all_plugins() -> None: - """Validate and register configured plugins. - - Registers built-in, directive-based, and global plugins from the configuration. - Plugin registration happens once at startup. - """ - - # Local - from .plugins import register_plugin, init_builtin_plugins - - state = use_state() - - # Register built-in plugins (FRR, Juniper, Mikrotik, etc.). - init_builtin_plugins() - - failures = () - - # Register external directive-based plugins (defined in devices.yaml). - for plugin_file, directives in state.devices.directive_plugins().items(): - failures += register_plugin(plugin_file, directives=directives) - - # Register external global/common plugins (defined in hyperglass.yaml). - for plugin_file in state.params.common_plugins(): - failures += register_plugin(plugin_file, common=True) - - for failure in failures: - log.bind(plugin=failure).warning("Invalid hyperglass plugin") - - -def unregister_all_plugins() -> None: - """Unregister all plugins and reset managers.""" - # Local - from .plugins import InputPluginManager, OutputPluginManager - - for manager in (InputPluginManager, OutputPluginManager): - manager().reset() - - -def start(*, log_level: t.Union[str, int], workers: int) -> None: - """Start hyperglass via ASGI server. - - This function triggers the UI build (if enabled), registers plugins, - and then hands off control to Uvicorn. - """ - - register_all_plugins() - - if not Settings.disable_ui: - asyncio.run(build_ui()) - - uvicorn.run( - app="hyperglass.api:app", - host=str(Settings.host), - port=Settings.port, - workers=workers, - log_level=log_level, - log_config={ - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "()": "uvicorn.logging.DefaultFormatter", - "format": "%(message)s", - }, - "access": { - "()": "uvicorn.logging.AccessFormatter", - "format": "%(message)s", - }, - }, - "handlers": { - "default": {"formatter": "default", "class": "hyperglass.log.LibInterceptHandler"}, - "access": {"formatter": "access", "class": "hyperglass.log.LibInterceptHandler"}, - }, - "loggers": { - "uvicorn.error": {"level": "ERROR", "handlers": ["default"], "propagate": False}, - "uvicorn.access": {"level": "INFO", "handlers": ["access"], "propagate": False}, - }, - }, - ) - - -def run(workers: int = None): - """Primary application runner. - - Initializes the user configuration, sets up file and syslog logging, - calculates worker counts, and starts the server. - """ - # Local - from .configuration import init_user_config - - try: - log.debug(repr(Settings)) - - # Reset state at start - state = use_state() - state.clear() - - # Load yaml configs - init_user_config() - - # Initialize logging targets - enable_file_logging( - directory=state.params.logging.directory, - max_size=state.params.logging.max_size, - log_format=state.params.logging.format, - level=LOG_LEVEL, - ) - - if state.params.logging.syslog is not None: - enable_syslog_logging( - host=state.params.logging.syslog.host, - port=state.params.logging.syslog.port, - ) - - # Calculate worker count based on CPU if not specified - _workers = workers - if workers is None: - if Settings.debug: - _workers = 1 - else: - _workers = cpu_count(2) - - log.bind( - version=__version__, - listening=f"http://{Settings.bind()}", - app_path=f"{Settings.app_path.absolute()!s}", - container=Settings.container, - original_app_path=f"{Settings.original_app_path.absolute()!s}", - workers=_workers, - ).info( - "Starting hyperglass", - ) - - start(log_level=LOG_LEVEL, workers=_workers) - log.bind(version=__version__).critical("Stopping hyperglass") - except Exception as error: - log.critical(error) - # Cleanup state on crash unless in dev mode - if not Settings.dev_mode: - state = use_state() - state.clear() - log.debug("Cleared hyperglass state") - unregister_all_plugins() - raise error - except (SystemExit, BaseException): - unregister_all_plugins() - sys.exit(4) - - -if __name__ == "__main__": - run() diff --git a/src/stale/hyperglass/hyperglass/models/__init__.py b/src/stale/hyperglass/hyperglass/models/__init__.py deleted file mode 100644 index 5aecd38..0000000 --- a/src/stale/hyperglass/hyperglass/models/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""All Data Models used by hyperglass.""" - -# Local -from .main import MultiModel, HyperglassModel, HyperglassModelWithId - -__all__ = ( - "MultiModel", - "HyperglassModel", - "HyperglassModelWithId", -) diff --git a/src/stale/hyperglass/hyperglass/models/api/__init__.py b/src/stale/hyperglass/hyperglass/models/api/__init__.py deleted file mode 100644 index 8f4bfcd..0000000 --- a/src/stale/hyperglass/hyperglass/models/api/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Query & Response Validation Models.""" - -# Local -from .query import Query -from .response import ( - QueryError, - InfoResponse, - QueryResponse, - RoutersResponse, - CommunityResponse, - SupportedQueryResponse, -) -from .cert_import import EncodedRequest - -__all__ = ( - "Query", - "QueryError", - "InfoResponse", - "QueryResponse", - "EncodedRequest", - "RoutersResponse", - "CommunityResponse", - "SupportedQueryResponse", -) diff --git a/src/stale/hyperglass/hyperglass/models/api/cert_import.py b/src/stale/hyperglass/hyperglass/models/api/cert_import.py deleted file mode 100644 index 215d248..0000000 --- a/src/stale/hyperglass/hyperglass/models/api/cert_import.py +++ /dev/null @@ -1,14 +0,0 @@ -"""hyperglass-agent certificate import models.""" - -# Standard Library -from typing import Union - -# Third Party -from pydantic import BaseModel, StrictStr, StrictBytes - - -class EncodedRequest(BaseModel): - """Certificate request model.""" - - device: StrictStr - encoded: Union[StrictStr, StrictBytes] diff --git a/src/stale/hyperglass/hyperglass/models/api/query.py b/src/stale/hyperglass/hyperglass/models/api/query.py deleted file mode 100644 index d416e98..0000000 --- a/src/stale/hyperglass/hyperglass/models/api/query.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Input query validation model. - -Defines the Pydantic models used to validate and transform user queries -received via the API or CLI. It integrates with the InputPluginManager -to apply platform-specific transformations. -""" - -# Standard Library -import typing as t -import hashlib -import secrets -from datetime import datetime - -# Third Party -from pydantic import BaseModel, ConfigDict, field_validator, StringConstraints -from typing_extensions import Annotated - -# Project -from hyperglass.log import log -from hyperglass.util import snake_to_camel, repr_from_attrs -from hyperglass.state import use_state -from hyperglass.plugins import InputPluginManager -from hyperglass.exceptions.public import InputInvalid, QueryTypeNotFound, QueryLocationNotFound -from hyperglass.exceptions.private import InputValidationError - -# Local -from ..config.devices import Device - -# TYPING: Strict string constraints for query parameters -QueryLocation = Annotated[str, StringConstraints(strict=True, min_length=1, strip_whitespace=True)] -QueryTarget = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] -QueryType = Annotated[str, StringConstraints(strict=True, min_length=1, strip_whitespace=True)] - - -class SimpleQuery(BaseModel): - """A simple representation of a post-validated query. - Used for logging and summarization. - """ - - query_location: str - query_target: t.Union[t.List[str], str] - query_type: str - - def __repr_name__(self) -> str: - """Alias SimpleQuery to Query for clarity in logging.""" - return "Query" - - -class Query(BaseModel): - """Primary validation model for user-supplied query parameters.""" - - model_config = ConfigDict(extra="allow", alias_generator=snake_to_camel, populate_by_name=True) - - # query_location maps to a Device name/id - query_location: QueryLocation - - # query_target is the IP, Prefix, or AS-Path to query - query_target: t.Union[t.List[QueryTarget], QueryTarget] - - # query_type maps to a Directive id (e.g. bgp_route) - query_type: QueryType - _kwargs: t.Dict[str, t.Any] - - def __init__(self, **data) -> None: - """Initialize the query and perform automated validation/transformation. - - Initialization sequence: - 1. Set UTC timestamp. - 2. Resolve the matching Directive for the requested query_type. - 3. Validate the query target against the directive rules and plugins. - 4. Apply input transformations (e.g., converting Cisco AS-Paths to BIRD format). - """ - super().__init__(**data) - self._kwargs = data - self.timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") - - state = use_state() - self._state = state - - # Match the query_type against the device's supported directives - query_directives = self.device.directives.matching(self.query_type) - - if len(query_directives) < 1: - raise QueryTypeNotFound(query_type=self.query_type) - - self.directive = query_directives[0] - - self._input_plugin_manager = InputPluginManager() - - try: - self.validate_query_target() - except InputValidationError as err: - raise InputInvalid(**err.kwargs) from err - - # TRANSFORM: Apply registered input plugins to the target - self.query_target = self.transform_query_target() - - def summary(self) -> SimpleQuery: - """Summarized and post-validated model of a Query.""" - return SimpleQuery( - query_location=self.query_location, - query_target=self.query_target, - query_type=self.query_type, - ) - - def __repr__(self) -> str: - """Represent only the query fields.""" - return repr_from_attrs(self, ("query_location", "query_type", "query_target")) - - def __str__(self) -> str: - """Alias __str__ to __repr__.""" - return repr(self) - - def digest(self) -> str: - """Create SHA256 hash digest of model representation. - Used as a cache key for query responses. - """ - return hashlib.sha256(repr(self).encode()).hexdigest() - - def random(self) -> str: - """Create a random string to prevent client or proxy caching.""" - return hashlib.sha256( - secrets.token_bytes(8) + repr(self).encode() + secrets.token_bytes(8) - ).hexdigest() - - def validate_query_target(self) -> None: - """Validate a query target after all fields/relationships have been initialized. - - Runs both the core directive validation (regex/rules) and any - active input plugins. - """ - # Run config/rule-based validations. - self.directive.validate_target(self.query_target) - # Run plugin-based validations. - self._input_plugin_manager.validate(query=self) - log.bind(query=self.summary()).debug("Validation passed") - - def transform_query_target(self) -> t.Union[t.List[str], str]: - """Transform a query target based on defined plugins. - This handles NOS-specific requirements like Juniper AS-Path formatting. - """ - return self._input_plugin_manager.transform(query=self) - - def dict(self) -> t.Dict[str, t.Union[t.List[str], str]]: - """Include only public fields.""" - return super().model_dump(include={"query_location", "query_target", "query_type"}) - - @property - def device(self) -> Device: - """Get this query's device object by resolving query_location from global state.""" - return self._state.devices[self.query_location] - - @field_validator("query_location") - def validate_query_location(cls, value): - """Ensure query_location exists in the configured device inventory.""" - - devices = use_state("devices") - - if not devices.valid_id_or_name(value): - raise QueryLocationNotFound(location=value) - - return value - - @field_validator("query_type") - def validate_query_type(cls, value: t.Any): - """Ensure the requested query type exists on AT LEAST ONE configured device.""" - devices = use_state("devices") - if any((device.has_directives(value) for device in devices)): - return value - - raise QueryTypeNotFound(query_type=value) diff --git a/src/stale/hyperglass/hyperglass/models/api/response.py b/src/stale/hyperglass/hyperglass/models/api/response.py deleted file mode 100644 index 188f0b0..0000000 --- a/src/stale/hyperglass/hyperglass/models/api/response.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Response model.""" - -# Standard Library -import typing as t - -# Third Party -from pydantic import Field, BaseModel, StrictInt, StrictStr, ConfigDict, StrictBool, field_validator - -# Project -from hyperglass.state import use_state - -ErrorName = t.Literal["success", "warning", "error", "danger"] -ResponseLevel = t.Literal["success"] -ResponseFormat = t.Literal[r"text/plain", r"application/json"] - -schema_query_output = { - "title": "Output", - "description": "Looking Glass Response", - "example": """ -BGP routing table entry for 1.1.1.0/24, version 224184946 -BGP Bestpath: deterministic-med -Paths: (12 available, best #1, table default) - Advertised to update-groups: - 1 40 - 13335, (aggregated by 13335 172.68.129.1), (received & used) - 192.0.2.1 (metric 51) from 192.0.2.1 (192.0.2.1) - Origin IGP, metric 0, localpref 250, valid, internal - Community: 65000:1 65000:2 - """, -} - -schema_query_level = {"title": "Level", "description": "Severity"} - -schema_query_random = { - "title": "Random", - "description": "Random string to prevent client or intermediate caching.", - "example": "504cbdb47eb8310ca237bf512c3e10b44b0a3d85868c4b64a20037dc1c3ef857", -} - -schema_query_cached = { - "title": "Cached", - "description": "`true` if the response is from a previously cached query.", -} - -schema_query_runtime = { - "title": "Runtime", - "description": "Time it took to run the query in seconds.", - "example": 6, -} - -schema_query_keywords = { - "title": "Keywords", - "description": "Relevant keyword values contained in the `output` field, which can be used for formatting.", - "example": ["1.1.1.0/24", "best #1"], -} - -schema_query_timestamp = { - "title": "Timestamp", - "description": "UTC Time at which the backend application received the query.", - "example": "2020-04-18 14:45:37", -} - -schema_query_format = { - "title": "Format", - "description": "Response [MIME Type](http://www.iana.org/assignments/media-types/media-types.xhtml). Supported values: `text/plain` and `application/json`.", - "example": "text/plain", -} - -schema_query_examples = [ - { - "output": """ -BGP routing table entry for 1.1.1.0/24, version 224184946 -BGP Bestpath: deterministic-med -Paths: (12 available, best #1, table default) - Advertised to update-groups: - 1 40 - 13335, (aggregated by 13335 172.68.129.1), (received & used) - 192.0.2.1 (metric 51) from 192.0.2.1 (192.0.2.1) - Origin IGP, metric 0, localpref 250, valid, internal - Community: 65000:1 65000:2 - """, - "level": "success", - "keywords": ["1.1.1.0/24", "best #1"], - } -] - -schema_query_error_output = { - "title": "Output", - "description": "Error Details", - "example": "192.0.2.1/32 is not allowed.", -} - -schema_query_error_level = {"title": "Level", "description": "Error Severity", "example": "danger"} - -schema_query_error_keywords = { - "title": "Keywords", - "description": "Relevant keyword values contained in the `output` field, which can be used for formatting.", - "example": ["192.0.2.1/32"], -} - - -class QueryError(BaseModel): - """Query response model.""" - - model_config = ConfigDict( - json_schema_extra={ - "title": "Query Error", - "description": "Response received when there is an error executing the requested query.", - "examples": [ - { - "output": "192.0.2.1/32 is not allowed.", - "level": "danger", - "keywords": ["192.0.2.1/32"], - } - ], - } - ) - - output: str = Field(json_schema_extra=schema_query_error_output) - level: ErrorName = Field("danger", json_schema_extra=schema_query_error_level) - # id: t.Optional[StrictStr] - keywords: t.List[StrictStr] = Field([], json_schema_extra=schema_query_error_keywords) - - @field_validator("output") - def validate_output(cls: "QueryError", value): - """If no output is specified, use a customizable generic message.""" - if value is None: - (messages := use_state("params").messages) - return messages.general - return value - - -class QueryResponse(BaseModel): - """Query response model.""" - - model_config = ConfigDict( - json_schema_extra={ - "title": "Query Response", - "description": "Looking glass response", - "examples": schema_query_examples, - } - ) - - output: t.Union[t.Dict, StrictStr] = Field(json_schema_extra=schema_query_output) - level: ResponseLevel = Field("success", json_schema_extra=schema_query_level) - random: str = Field(json_schema_extra=schema_query_random) - cached: bool = Field(json_schema_extra=schema_query_cached) - runtime: int = Field(json_schema_extra=schema_query_runtime) - keywords: t.List[str] = Field([], json_schema_extra=schema_query_keywords) - timestamp: str = Field(json_schema_extra=schema_query_timestamp) - format: ResponseFormat = Field("text/plain", json_schema_extra=schema_query_format) - - -class RoutersResponse(BaseModel): - """Response model for /api/devices list items.""" - - model_config = ConfigDict( - json_schema_extra={ - "title": "Device", - "description": "Device attributes", - "examples": [ - {"id": "nyc_router_1", "name": "NYC Router 1", "group": "New York City, NY"} - ], - } - ) - - id: StrictStr - name: StrictStr - group: t.Union[StrictStr, None] - - -class CommunityResponse(BaseModel): - """Response model for /api/communities.""" - - community: StrictStr - display_name: StrictStr - description: StrictStr - - -class SupportedQueryResponse(BaseModel): - """Response model for /api/queries list items.""" - - model_config = ConfigDict( - json_schema_extra={ - "title": "Query Type", - "description": "If enabled is `true`, the `name` field may be used to specify the query type.", - "examples": [{"name": "bgp_route", "display_name": "BGP Route", "enable": True}], - } - ) - - name: StrictStr - display_name: StrictStr - enable: StrictBool - - -class InfoResponse(BaseModel): - """Response model for /api/info endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "title": "System Information", - "description": "General information about this looking glass.", - "examples": [ - { - "name": "hyperglass", - "organization": "Company Name", - "primary_asn": 65000, - "version": "hyperglass 1.0.0-beta.52", - } - ], - } - ) - - name: StrictStr - organization: StrictStr - primary_asn: StrictInt - version: StrictStr diff --git a/src/stale/hyperglass/hyperglass/models/api/rfc8522.py b/src/stale/hyperglass/hyperglass/models/api/rfc8522.py deleted file mode 100644 index f7310b5..0000000 --- a/src/stale/hyperglass/hyperglass/models/api/rfc8522.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Response model.""" - -# Standard Library -# flake8: noqa -import math -import typing as t -import secrets -from datetime import datetime - -# Third Party -from pydantic import Field, BaseModel, ConfigDict, field_validator - -"""Patterns: -GET /.well-known/looking-glass/v1/ping/2001:DB8::35?protocol=2,1 -GET /.well-known/looking-glass/v1/traceroute/192.0.2.8?routerindex=5 -GET /.well-known/looking-glass/v1/show/route/2001:DB8::/48?protocol=2,1 -GET /.well-known/looking-glass/v1/show/bgp/192.0.2.0/24 -GET /.well-known/looking-glass/v1/show/bgp/summary?protocol=2&routerindex=3 -GET /.well-known/looking-glass/v1/show/bgp/neighbors/192.0.2.226 -GET /.well-known/looking-glass/v1/routers -GET /.well-known/looking-glass/v1/routers/1 -GET /.well-known/looking-glass/v1/cmd -""" - -QueryFormat = t.Literal[r"text/plain", r"application/json"] - - -class _HyperglassQuery(BaseModel): - model_config = ConfigDict(validate_assignment=True, validate_default=True) - - -class BaseQuery(_HyperglassQuery): - protocol: str = "1,1" - router: str - routerindex: int - random: str = secrets.token_urlsafe(16) - runtime: int = 30 - query_format: QueryFormat = Field("text/plain", alias="format") - - @field_validator("runtime") - def validate_runtime(cls, value): - if isinstance(value, float) and math.modf(value)[0] == 0: - value = math.ceil(value) - return value - - -class BaseData(_HyperglassQuery): - model_config = ConfigDict(extra="allow") - - router: str - performed_at: datetime - runtime: t.Union[float, int] - output: t.List[str] - data_format: str = Field(alias="format") - - @field_validator("runtime") - def validate_runtime(cls, value): - if isinstance(value, float) and math.modf(value)[0] == 0: - value = math.ceil(value) - return value - - -class QueryError(_HyperglassQuery): - status: t.Literal["error"] - message: str - - -class QueryResponse(_HyperglassQuery): - status: t.Literal["success", "fail"] - data: BaseData diff --git a/src/stale/hyperglass/hyperglass/models/api/types.py b/src/stale/hyperglass/hyperglass/models/api/types.py deleted file mode 100644 index 397ca8f..0000000 --- a/src/stale/hyperglass/hyperglass/models/api/types.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Custom validation types.""" - -# Standard Library -import typing as t - -# Third Party -from pydantic import AfterValidator - -# Project -from hyperglass.constants import SUPPORTED_QUERY_TYPES - - -def validate_query_type(value: str) -> str: - """Ensure query type is supported by hyperglass.""" - if value not in SUPPORTED_QUERY_TYPES: - raise ValueError("'{}' is not a supported query type".format(value)) - return value - - -SupportedQuery = t.Annotated[str, AfterValidator(validate_query_type)] diff --git a/src/stale/hyperglass/hyperglass/models/config/__init__.py b/src/stale/hyperglass/hyperglass/models/config/__init__.py deleted file mode 100644 index 1bff0e6..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Define models for all config variables. - -Import config variables and overrides default class attributes. -Validate input for overridden parameters. -""" diff --git a/src/stale/hyperglass/hyperglass/models/config/cache.py b/src/stale/hyperglass/hyperglass/models/config/cache.py deleted file mode 100644 index f3c3e93..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/cache.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Validation model for cache config.""" - -# Local -from ..main import HyperglassModel - - -class Cache(HyperglassModel): - """Public cache parameters.""" - - timeout: int = 120 - show_text: bool = True diff --git a/src/stale/hyperglass/hyperglass/models/config/credential.py b/src/stale/hyperglass/hyperglass/models/config/credential.py deleted file mode 100644 index 905f22e..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/credential.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Validate credential configuration variables.""" - -# Standard Library -import typing as t - -# Third Party -from pydantic import FilePath, SecretStr, model_validator - -# Local -from ..main import HyperglassModel - -AuthMethod = t.Literal["password", "unencrypted_key", "encrypted_key"] - - -class Credential(HyperglassModel, extra="allow"): - """Model for per-credential config in devices.yaml.""" - - username: str - password: t.Optional[SecretStr] = None - key: t.Optional[FilePath] = None - _method: t.Optional[AuthMethod] = None - - @model_validator(mode="after") - def validate_credential(cls, data: "Credential"): - """Ensure either a password or an SSH key is set.""" - if data.key is None and data.password is None: - raise ValueError( - "Either a password or an SSH key must be specified for user '{}'".format( - data.username - ) - ) - return data - - def __init__(self, **kwargs): - """Set private attribute _method based on validated model.""" - super().__init__(**kwargs) - self._method = None - if self.password is not None and self.key is not None: - self._method = "encrypted_key" - elif self.password is None: - self._method = "unencrypted_key" - elif self.key is None: - self._method = "password" diff --git a/src/stale/hyperglass/hyperglass/models/config/devices.py b/src/stale/hyperglass/hyperglass/models/config/devices.py deleted file mode 100644 index 70cac21..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/devices.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Validate router configuration variables.""" - -# Standard Library -import re -import typing as t -from pathlib import Path -from ipaddress import IPv4Address, IPv6Address - -# Third Party -from pydantic import FilePath, ValidationInfo, field_validator -from netmiko.ssh_dispatcher import CLASS_MAPPER # type: ignore - -# Project -from hyperglass.log import log -from hyperglass.util import get_driver, get_fmt_keys, resolve_hostname -from hyperglass.state import use_state -from hyperglass.settings import Settings -from hyperglass.constants import ( - DRIVER_MAP, - SCRAPE_HELPERS, - LINUX_PLATFORMS, - SUPPORTED_STRUCTURED_OUTPUT, -) -from hyperglass.exceptions.private import ConfigError, UnsupportedDevice - -# Local -from ..main import MultiModel, HyperglassModel, HyperglassModelWithId -from ..util import check_legacy_fields -from .proxy import Proxy -from ..fields import SupportedDriver -from ..directive import Directives -from .credential import Credential -from .http_client import HttpConfiguration - -ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()} - - -class APIDevice(t.TypedDict): - """API Response Model for a device.""" - - id: str - name: str - group: t.Union[str, None] - - -class DirectiveOptions(HyperglassModel, extra="ignore"): - """Per-device directive options.""" - - builtins: t.Union[bool, t.List[str]] = True - - -class Device(HyperglassModelWithId, extra="allow"): - """Validation model for per-router config in devices.yaml.""" - - id: str - name: str - description: t.Optional[str] = None - avatar: t.Optional[FilePath] = None - address: t.Union[IPv4Address, IPv6Address, str] - group: t.Optional[str] = None - credential: Credential - proxy: t.Optional[Proxy] = None - display_name: t.Optional[str] = None - port: int = 22 - http: HttpConfiguration = HttpConfiguration() - platform: str - structured_output: t.Optional[bool] = None - directives: Directives = Directives() - driver: t.Optional[SupportedDriver] = None - driver_config: t.Dict[str, t.Any] = {} - attrs: t.Dict[str, str] = {} - - def __init__(self, **kw) -> None: - """Check legacy fields and ensure an `id` is set.""" - kw = check_legacy_fields(model="Device", data=kw) - if "id" not in kw: - kw = self._with_id(kw) - super().__init__(**kw) - self._validate_directive_attrs() - - @property - def _target(self): - return str(self.address) - - @staticmethod - def _with_id(values: t.Dict) -> str: - """Generate device id & handle legacy display_name field.""" - - def generate_id(name: str) -> str: - scrubbed = re.sub(r"[^A-Za-z0-9\_\-\s]", "", name) - return "_".join(scrubbed.split()).lower() - - name = values.pop("name", None) - - if name is None: - raise ValueError("name is required.") - - device_id = generate_id(name) - display_name = name - - return {"id": device_id, "name": display_name, "display_name": None, **values} - - def export_api(self) -> APIDevice: - """Export API-facing device fields.""" - return { - "id": self.id, - "name": self.name, - "group": self.group, - } - - @property - def directive_commands(self) -> t.List[str]: - """Get all commands associated with the device.""" - return [ - command - for directive in self.directives - for rule in directive.rules - for command in rule.commands - ] - - @property - def directive_ids(self) -> t.List[str]: - """Get all directive IDs associated with the device.""" - return [directive.id for directive in self.directives] - - @property - def directive_names(self) -> t.List[str]: - """Get all directive names associated with the device.""" - return list({directive.name for directive in self.directives}) - - def has_directives(self, *directive_ids: str) -> bool: - """Determine if a directive is used on this device.""" - for directive_id in directive_ids: - if directive_id in self.directive_ids: - return True - return False - - def get_device_type(self) -> str: - """Get the `device_type` field for use by Netmiko. - - In some cases, the platform might be different than the - device_type. For example, any linux-based platform like FRR, - BIRD, or OpenBGPD will have directives associated with those - platforms, but the `device_type` sent to Netmiko needs to be - `linux_ssh`. - """ - if self.platform in LINUX_PLATFORMS: - return "linux_ssh" - return self.platform - - def _validate_directive_attrs(self) -> None: - # Set of all keys except for built-in key `target`. - keys = { - key - for group in [get_fmt_keys(command) for command in self.directive_commands] - for key in group - if key != "target" - } - - attrs = {k: v for k, v in self.attrs.items() if k in keys} - - # Verify all keys in associated commands contain values in device's `attrs`. - for key in keys: - if key not in attrs: - raise ConfigError( - "Device '{d}' has a command that references attribute '{a}', but '{a}' is missing from device attributes", - d=self.name, - a=key, - ) - - @field_validator("address") - def validate_address( - cls, value: t.Union[IPv4Address, IPv6Address, str], info: ValidationInfo - ) -> t.Union[IPv4Address, IPv6Address, str]: - """Ensure a hostname is resolvable.""" - - if not isinstance(value, (IPv4Address, IPv6Address)): - if not any(resolve_hostname(value)): - raise ConfigError( - "Device '{d}' has an address of '{a}', which is not resolvable.", - d=info.data["name"], - a=value, - ) - return value - - @field_validator("avatar") - def validate_avatar( - cls, value: t.Union[FilePath, None], info: ValidationInfo - ) -> t.Union[FilePath, None]: - """Migrate avatar to static directory.""" - if value is not None: - # Standard Library - import shutil - - # Third Party - from PIL import Image - - target = Settings.static_path / "images" / value.name - copied = shutil.copy2(value, target) - log.bind( - device=info.data["name"], - source=str(value), - destination=str(target), - ).debug("Copied device avatar") - - with Image.open(copied) as src: - if src.width > 512: - src.thumbnail((512, 512 * src.height / src.width)) - src.save(target) - return value - - @field_validator("platform", mode="before") - def validate_platform(cls: "Device", value: t.Any, info: ValidationInfo) -> str: - """Validate & rewrite device platform, set default `directives`.""" - - if value == "http": - if info.data.get("http") is None: - raise ConfigError( - "Device '{device}' has platform 'http' configured, but no http parameters are defined.", - device=info.data["name"], - ) - - if value is None: - if info.data.get("http") is not None: - value = "http" - else: - # Ensure device platform is defined. - raise ConfigError( - "Device '{device}' is missing a 'platform' (Network Operating System) property", - device=info.data["name"], - ) - - if value in SCRAPE_HELPERS.keys(): - # Rewrite platform to helper value if needed. - value = SCRAPE_HELPERS[value] - - # Verify device platform is supported by hyperglass. - if value not in ALL_DEVICE_TYPES: - raise UnsupportedDevice(value) - - return value - - @field_validator("structured_output", mode="before") - def validate_structured_output(cls, value: bool, info: ValidationInfo) -> bool: - """Validate structured output is supported on the device & set a default.""" - - if value is True: - if info.data.get("platform") not in SUPPORTED_STRUCTURED_OUTPUT: - raise ConfigError( - "The 'structured_output' field is set to 'true' on device '{}' with " - + "platform '{}', which does not support structured output", - info.data.get("name"), - info.data.get("platform"), - ) - return value - if value is None and info.data.get("platform") in SUPPORTED_STRUCTURED_OUTPUT: - value = True - else: - value = False - return value - - @field_validator("directives", mode="before") - def validate_directives( - cls: "Device", value: t.Optional[t.List[str]], info: ValidationInfo - ) -> "Directives": - """Associate directive IDs to loaded directive objects.""" - directives = use_state("directives") - - directive_ids = value or [] - structured_output = info.data.get("structured_output", False) - platform = info.data.get("platform") - - # Directive options - directive_options = DirectiveOptions( - **{ - k: v - for statement in directive_ids - if isinstance(statement, t.Dict) - for k, v in statement.items() - } - ) - - # String directive IDs, excluding builtins and options. - directive_ids = [ - statement - for statement in directive_ids - if isinstance(statement, str) and not statement.startswith("__") - ] - # Directives matching provided IDs. - device_directives = directives.filter(*directive_ids) - # Matching built-in directives for this device's platform. - builtins = directives.device_builtins(platform=platform, table_output=structured_output) - - if directive_options.builtins is True: - # Add all builtins. - device_directives += builtins - elif isinstance(directive_options.builtins, t.List): - # If the user provides a list of builtin directives to include, add only those. - device_directives += builtins.matching(*directive_options.builtins) - - return device_directives - - @field_validator("driver") - def validate_driver(cls: "Device", value: t.Optional[str], info: ValidationInfo) -> str: - """Set the correct driver and override if supported.""" - return get_driver(info.data.get("platform"), value) - - -class Devices(MultiModel, model=Device, unique_by="id"): - """Container for all devices.""" - - def __init__(self: "Devices", *items: t.Dict[str, t.Any]) -> None: - """Generate IDs prior to validation.""" - with_id = (Device._with_id(item) for item in items) - super().__init__(*with_id) - - def export_api(self: "Devices") -> t.List[APIDevice]: - """Export API-facing device fields.""" - return [d.export_api() for d in self] - - def valid_id_or_name(self: "Devices", value: str) -> bool: - """Determine if a value is a valid device name or ID.""" - for device in self: - if value == device.id or value == device.name: - return True - return False - - def directive_plugins(self: "Devices") -> t.Dict[Path, t.Tuple[str]]: - """Get a mapping of plugin paths to associated directive IDs.""" - result: t.Dict[Path, t.Set[str]] = {} - # Unique set of all directives. - directives = {directive for device in self for directive in device.directives} - # Unique set of all plugin file names. - plugin_names = {plugin for directive in directives for plugin in directive.plugins} - - for directive in directives: - # Convert each plugin file name to a `Path` object. - for plugin in (Path(p) for p in directive.plugins if p in plugin_names): - if plugin not in result: - result[plugin] = set() - result[plugin].add(directive.id) - # Convert the directive set to a tuple. - return {k: tuple(v) for k, v in result.items()} - - def directive_names(self) -> t.List[str]: - """Get all directive names for all devices.""" - return list({directive.name for device in self for directive in device.directives}) - - def frontend(self: "Devices") -> t.List[t.Dict[str, t.Any]]: - """Export grouped devices for UIParameters.""" - groups = {device.group for device in self} - return [ - { - "group": group, - "locations": [ - { - "group": group, - "id": device.id, - "name": device.name, - "avatar": f"/images/{device.avatar.name}" - if device.avatar is not None - else None, - "description": device.description, - "directives": [d.frontend() for d in device.directives], - } - for device in self - if device.group == group - ], - } - for group in groups - ] diff --git a/src/stale/hyperglass/hyperglass/models/config/docs.py b/src/stale/hyperglass/hyperglass/models/config/docs.py deleted file mode 100644 index d219a75..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/docs.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Configuration for API docs feature.""" - -# Standard Library -import typing as t - -# Third Party -from pydantic import Field, HttpUrl - -# Local -from ..main import HyperglassModel -from ..fields import AnyUri - -DocsMode = t.Literal["swagger", "redoc"] - - -class EndpointConfig(HyperglassModel): - """Validation model for per API endpoint documentation.""" - - title: str = Field( - ..., - title="Endpoint Title", - description="Displayed as the header text above the API endpoint section.", - ) - description: str = Field( - ..., - title="Endpoint Description", - description="Displayed inside each API endpoint section.", - ) - summary: str = Field( - ..., - title="Endpoint Summary", - description="Displayed beside the API endpoint URI.", - ) - - -class Docs(HyperglassModel): - """Validation model for params.docs.""" - - enable: bool = Field(True, title="Enable", description="Enable or disable API documentation.") - base_url: HttpUrl = Field( - "https://lg.example.net", - title="Base URL", - description="Base URL used in request samples.", - ) - path: AnyUri = Field( - "/api/docs", - title="URI", - description="HTTP URI/path where API documentation can be accessed.", - ) - title: str = Field( - "{site_title} API Documentation", - title="Title", - description="API documentation title. `{site_title}` may be used to display the `site_title` parameter.", - ) - description: str = Field( - "", - title="Description", - description="API documentation description appearing below the title.", - ) - query: EndpointConfig = EndpointConfig( - title="Submit Query", - description="Request a query response per-location.", - summary="Query the Looking Glass", - ) - devices: EndpointConfig = EndpointConfig( - title="Devices", - description="List of all devices/locations with associated identifiers, display names, networks, & VRFs.", - summary="Devices List", - ) - queries: EndpointConfig = EndpointConfig( - title="Supported Queries", - description="List of supported query types.", - summary="Query Types", - ) - info: EndpointConfig = EndpointConfig( - title="System Information", - description="General information about this looking glass.", - summary="System Information", - ) diff --git a/src/stale/hyperglass/hyperglass/models/config/http_client.py b/src/stale/hyperglass/hyperglass/models/config/http_client.py deleted file mode 100644 index a2f2ff1..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/http_client.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Configuration models for hyperglass http client.""" - -# Standard Library -import typing as t - -# Third Party -import httpx -from pydantic import FilePath, SecretStr, PrivateAttr, IPvAnyAddress - -# Project -from hyperglass.models import HyperglassModel -from hyperglass.constants import __version__ - -# Local -from ..fields import IntFloat, HttpMethod, Primitives - -if t.TYPE_CHECKING: - # Local - from .devices import Device - -DEFAULT_QUERY_PARAMETERS: t.Dict[str, str] = { - "query_target": "{query_target}", - "query_type": "{query_type}", - "query_location": "{query_location}", -} - -BodyFormat = t.Literal["json", "yaml", "xml", "text"] -Scheme = t.Literal["http", "https"] - - -class AttributeMapConfig(HyperglassModel): - """Allow the user to 'rewrite' hyperglass field names to their own values.""" - - query_target: t.Optional[str] = None - query_type: t.Optional[str] = None - query_location: t.Optional[str] = None - - -class AttributeMap(HyperglassModel): - """Merged implementation of attribute map configuration.""" - - query_target: str - query_type: str - query_location: str - - -class HttpBasicAuth(HyperglassModel): - """Configuration model for HTTP basic authentication.""" - - username: str - password: SecretStr - - -class HttpConfiguration(HyperglassModel): - """HTTP client configuration.""" - - _attribute_map: AttributeMap = PrivateAttr() - path: str = "/" - method: HttpMethod = "GET" - scheme: Scheme = "https" - query: t.Optional[t.Union[t.Literal[False], t.Dict[str, Primitives]]] = None - verify_ssl: bool = True - ssl_ca: t.Optional[FilePath] = None - ssl_client: t.Optional[FilePath] = None - source: t.Optional[IPvAnyAddress] = None - timeout: IntFloat = 5 - headers: t.Dict[str, str] = {} - follow_redirects: bool = False - basic_auth: t.Optional[HttpBasicAuth] = None - attribute_map: AttributeMapConfig = AttributeMapConfig() - body_format: BodyFormat = "json" - retries: int = 0 - - def __init__(self, **data: t.Any) -> None: - """Create HTTP Client Configuration Definition.""" - - super().__init__(**data) - self._attribute_map = self._create_attribute_map() - - def _create_attribute_map(self) -> AttributeMap: - """Create AttributeMap instance with defined overrides.""" - - return AttributeMap( - query_location=self.attribute_map.query_location or "query_location", - query_type=self.attribute_map.query_type or "query_type", - query_target=self.attribute_map.query_target or "query_target", - ) - - def create_client(self, *, device: "Device") -> httpx.AsyncClient: - """Create a pre-configured http client.""" - - # Use the CA certificates for SSL verification, if present. - verify = self.verify_ssl - if self.ssl_ca is not None: - verify = httpx.create_ssl_context(verify=str(self.ssl_ca)) - - transport_constructor = {"retries": self.retries} - - # Use `source` IP address as httpx transport's `local_address`, if defined. - if self.source is not None: - transport_constructor["local_address"] = str(self.source) - - transport = httpx.AsyncHTTPTransport(**transport_constructor) - - # Add the port to the URL only if it is not 22, 80, or 443. - base_url = f"{self.scheme}://{device.address!s}".strip("/") - if device.port not in (22, 80, 443): - base_url += f":{device.port!s}" - - parameters = { - "verify": verify, - "transport": transport, - "timeout": self.timeout, - "follow_redirects": self.follow_redirects, - "base_url": f"{self.scheme}://{device.address!s}".strip("/"), - "headers": {"user-agent": f"hyperglass/{__version__}", **self.headers}, - } - - # Use client certificate authentication, if defined. - if self.ssl_client is not None: - parameters["cert"] = str(self.ssl_client) - - # Use basic authentication, if defined. - if self.basic_auth is not None: - parameters["auth"] = httpx.BasicAuth( - username=self.basic_auth.username, - password=self.basic_auth.password.get_secret_value(), - ) - - return httpx.AsyncClient(**parameters) diff --git a/src/stale/hyperglass/hyperglass/models/config/logging.py b/src/stale/hyperglass/hyperglass/models/config/logging.py deleted file mode 100644 index 3604dd0..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/logging.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Validate logging configuration.""" - -# Standard Library -import typing as t -from pathlib import Path - -# Third Party -from pydantic import ByteSize, SecretStr, AnyHttpUrl, DirectoryPath, field_validator - -# Project -from hyperglass.constants import __version__ - -# Local -from ..main import HyperglassModel -from ..fields import LogFormat, HttpAuthMode, HttpProvider - - -class Syslog(HyperglassModel): - """Validation model for syslog configuration.""" - - enable: bool = True - host: str - port: int = 514 - - -class HttpAuth(HyperglassModel): - """HTTP hook authentication parameters.""" - - mode: HttpAuthMode = "basic" - username: t.Optional[str] = None - password: SecretStr - header: str = "x-api-key" - - def api_key(self): - """Represent authentication as an API key header.""" - return {self.header: self.password.get_secret_value()} - - def basic(self): - """Represent HTTP basic authentication.""" - return (self.username, self.password.get_secret_value()) - - -class Http(HyperglassModel, extra="allow"): - """HTTP logging parameters.""" - - enable: bool = True - provider: HttpProvider = "generic" - host: AnyHttpUrl - authentication: t.Optional[HttpAuth] = None - headers: t.Dict[str, t.Union[str, int, bool, None]] = {} - params: t.Dict[str, t.Union[str, int, bool, None]] = {} - verify_ssl: bool = True - timeout: t.Union[float, int] = 5.0 - - @field_validator("headers", "params") - def stringify_headers_params(cls, value): - """Ensure headers and URL parameters are strings.""" - for k, v in value.items(): - if not isinstance(v, str): - value[k] = str(v) - return value - - def __init__(self, **kwargs): - """Initialize model, add obfuscated connection details as attribute.""" - super().__init__(**kwargs) - dumped = { - "headers": self.headers, - "params": self.params, - "verify": self.verify_ssl, - "timeout": self.timeout, - } - dumped["headers"].update({"user-agent": f"hyperglass/{__version__}"}) - - if self.authentication is not None: - if self.authentication.mode == "api_key": - dumped["headers"].update(self.authentication.api_key()) - else: - dumped["auth"] = self.authentication.basic() - - -class Logging(HyperglassModel): - """Validation model for logging configuration.""" - - directory: DirectoryPath = Path("/tmp") # noqa: S108 - format: LogFormat = "text" - max_size: ByteSize = "50MB" - syslog: t.Optional[Syslog] = None - http: t.Optional[Http] = None diff --git a/src/stale/hyperglass/hyperglass/models/config/messages.py b/src/stale/hyperglass/hyperglass/models/config/messages.py deleted file mode 100644 index cb47f0d..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/messages.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Validate error message configuration variables.""" - -# Third Party -from pydantic import Field, ConfigDict - -# Local -from ..main import HyperglassModel - - -class Messages(HyperglassModel): - """Validation model for params.messages.""" - - model_config = ConfigDict( - title="Messages", - description="Customize almost all user-facing UI & API messages.", - json_schema_extra={"level": 2}, - ) - - no_input: str = Field( - "{field} must be specified.", - title="No Input", - description="Displayed when no a required field is not specified. `{field}` may be used to display the `display_name` of the field that was omitted.", - ) - target_not_allowed: str = Field( - "{target} is not allowed.", - title="Target Not Allowed", - description="Displayed when a query target is implicitly denied by a configured rule. `{target}` will be used to display the denied query target.", - ) - feature_not_enabled: str = Field( - "{feature} is not enabled.", - title="Feature Not Enabled", - description="Displayed when a query type is submitted that is not supported or disabled. The hyperglass UI performs validation of supported query types prior to submitting any requests, so this is primarily relevant to the hyperglass API. `{feature}` may be used to display the disabled feature.", - ) - invalid_input: str = Field( - "{target} is not valid.", - title="Invalid Input", - description="Displayed when a query target's value is invalid in relation to the corresponding query type. `{target}` may be used to display the invalid target.", - ) - invalid_query: str = Field( - "{target} is not a valid {query_type} target.", - title="Invalid Query", - description="Displayed when a query target's value is invalid in relation to the corresponding query type. `{target}` and `{query_type}` may be used to display the invalid target and corresponding query type.", - ) - invalid_field: str = Field( - "{input} is an invalid {field}.", - title="Invalid Field", - description="Displayed when a query field contains an invalid or unsupported value. `{input}` and `{field}` may be used to display the invalid input value and corresponding field name.", - ) - general: str = Field( - "Something went wrong.", - title="General Error", - description="Displayed when generalized errors occur. Seeing this error message may indicate a bug in hyperglass, as most other errors produced are highly contextual. If you see this in the wild, try enabling [debug mode](/fixme) and review the logs to pinpoint the source of the error.", - ) - not_found: str = Field( - "{type} '{name}' not found.", - title="Not Found", - description="Displayed when an object property does not exist in the configuration. `{type}` corresponds to a user-friendly name of the object type (for example, 'Device'), `{name}` corresponds to the object name that was not found.", - ) - request_timeout: str = Field( - "Request timed out.", - title="Request Timeout", - description="Displayed when the [request_timeout](/fixme) time expires.", - ) - connection_error: str = Field( - "Error connecting to {device_name}: {error}", - title="Displayed when hyperglass is unable to connect to a configured device. Usually, this indicates a configuration error. `{device_name}` and `{error}` may be used to display the device in question and the specific connection error.", - ) - authentication_error: str = Field( - "Authentication error occurred.", - title="Authentication Error", - description="Displayed when hyperglass is unable to authenticate to a configured device. Usually, this indicates a configuration error.", - ) - no_response: str = Field( - "No response.", - title="No Response", - description="Displayed when hyperglass can connect to a device, but no output able to be read. Seeing this error may indicate a bug in hyperglas or one of its dependencies. If you see this in the wild, try enabling [debug mode](/fixme) and review the logs to pinpoint the source of the error.", - ) - no_output: str = Field( - "The query completed, but no matching results were found.", - title="No Output", - description="Displayed when hyperglass can connect to a device and execute a query, but the response is empty.", - ) - - def has(self, attr: str) -> bool: - """Determine if message type exists in Messages model.""" - return attr in self.model_dump().keys() - - def __getitem__(self, attr: str) -> str: - """Make messages subscriptable.""" - - if not self.has(attr): - raise KeyError(f"'{attr}' does not exist on Messages model") - return getattr(self, attr) diff --git a/src/stale/hyperglass/hyperglass/models/config/opengraph.py b/src/stale/hyperglass/hyperglass/models/config/opengraph.py deleted file mode 100644 index 5406ca5..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/opengraph.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Validate OpenGraph Configuration Parameters.""" - -# Standard Library -from pathlib import Path - -# Third Party -from pydantic import FilePath, field_validator - -# Local -from ..main import HyperglassModel - -DEFAULT_IMAGES = Path(__file__).parent.parent.parent / "images" - - -class OpenGraph(HyperglassModel): - """Validation model for params.opengraph.""" - - image: FilePath = DEFAULT_IMAGES / "hyperglass-opengraph.jpg" - - @field_validator("image") - def validate_opengraph(cls, value): - """Ensure the opengraph image is a supported format.""" - supported_extensions = (".jpg", ".jpeg", ".png") - if value is not None and value.suffix not in supported_extensions: - raise ValueError( - "OpenGraph image must be one of {e}".format(e=", ".join(supported_extensions)) - ) - - return value diff --git a/src/stale/hyperglass/hyperglass/models/config/params.py b/src/stale/hyperglass/hyperglass/models/config/params.py deleted file mode 100644 index 1e35ba2..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/params.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Configuration validation entry point.""" - -# Standard Library -import typing as t -import urllib.parse -from pathlib import Path - -# Third Party -from pydantic import Field, HttpUrl, ConfigDict, ValidationInfo, field_validator - -# Project -from hyperglass.settings import Settings -from hyperglass.constants import __version__ - -# Local -from .web import Web -from .docs import Docs -from ..main import HyperglassModel -from .cache import Cache -from .logging import Logging -from .messages import Messages -from .structured import Structured - -Localhost = t.Literal["localhost"] - - -class APIParams(t.TypedDict): - """/api/info response model.""" - - name: str - organization: str - primary_asn: int - version: str - - -class ParamsPublic(HyperglassModel): - """Public configuration parameters.""" - - request_timeout: int = Field( - 90, - title="Request Timeout", - description="Global timeout in seconds for all requests. The frontend application (UI) uses this field's exact value when submitting queries. The backend application uses this field's value, minus one second, for its own timeout handling. This is to ensure a contextual timeout error is presented to the end user in the event of a backend application timeout.", - ) - primary_asn: t.Union[int, str] = Field( - "65001", - title="Primary ASN", - description="Your network's primary ASN. This field is used to set some useful defaults such as the subtitle and PeeringDB URL.", - ) - org_name: str = Field( - "Beloved Hyperglass User", - title="Organization Name", - description="Your organization's name. This field is used in the UI & API documentation to set fields such as `` HTML tags for SEO and the terms & conditions footer component.", - ) - site_title: str = Field( - "hyperglass", - title="Site Title", - description="The name of your hyperglass site. This field is used in the UI & API documentation to set fields such as the `` HTML tag, and the terms & conditions footer component.", - ) - site_description: str = Field( - "{org_name} Network Looking Glass", - title="Site Description", - description='A short description of your hyperglass site. This field is used in th UI & API documentation to set the `<meta name="description"/>` tag. `{org_name}` may be used to insert the value of the `org_name` field.', - ) - - -class Params(ParamsPublic, HyperglassModel): - """Validation model for all configuration variables.""" - - model_config = ConfigDict(json_schema_extra={"level": 1}) - - # Top Level Params - - fake_output: bool = Field( - False, - title="Fake Output", - description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.", - ) - cors_origins: t.List[str] = Field( - [], - title="Cross-Origin Resource Sharing", - description="Allowed CORS hosts. By default, no CORS hosts are allowed.", - ) - plugins: t.List[str] = [] - - # Sub Level Params - cache: Cache = Cache() - docs: Docs = Docs() - logging: Logging = Logging() - messages: Messages = Messages() - structured: Structured = Structured() - web: Web = Web() - - def __init__(self, **kw: t.Any) -> None: - return super().__init__(**self.convert_paths(kw)) - - @field_validator("site_description") - def validate_site_description(cls: "Params", value: str, info: ValidationInfo) -> str: - """Format the site description with the org_name field.""" - return value.format(org_name=info.data.get("org_name")) - - @field_validator("primary_asn") - def validate_primary_asn(cls: "Params", value: t.Union[int, str]) -> str: - """Stringify primary_asn if passed as an integer.""" - return str(value) - - @field_validator("plugins") - def validate_plugins(cls: "Params", value: t.List[str]) -> t.List[str]: - """Validate and register configured plugins.""" - plugin_dir = Settings.app_path / "plugins" - - if plugin_dir.exists(): - # Path objects whose file names match configured file names, should work - # whether or not file extension is specified. - matching_plugins = ( - f - for f in plugin_dir.iterdir() - if f.name.split(".")[0] in (p.split(".")[0] for p in value) - ) - return [str(f) for f in matching_plugins] - return [] - - @field_validator("web", mode="after") - @classmethod - def validate_web(cls, web: Web, info: ValidationInfo) -> Web: - """String-format Link URLs.""" - for link in web.links: - url = urllib.parse.unquote(str(link.url), encoding="utf-8", errors="replace").format( - primary_asn=info.data.get("primary_asn", "65000") - ) - link.url = HttpUrl(url) - - for menu in web.menus: - menu.content = menu.content.format( - site_title=info.data.get("site_title", "hyperglass"), - org_name=info.data.get("org_name", "hyperglass"), - version=__version__, - ) - return web - - def common_plugins(self) -> t.Tuple[Path, ...]: - """Get all validated external common plugins as Path objects.""" - return tuple(Path(p) for p in self.plugins) - - def export_api(self) -> APIParams: - """Export API-specific parameters.""" - return { - "name": self.site_title, - "organization": self.org_name, - "primary_asn": int(self.primary_asn), - "version": __version__, - } - - def frontend(self) -> t.Dict[str, t.Any]: - """Export UI-specific parameters.""" - - return self.export_dict( - include={ - "cache": {"show_text", "timeout"}, - "developer_mode": ..., - "primary_asn": ..., - "request_timeout": ..., - "org_name": ..., - "site_title": ..., - "site_description": ..., - "web": ..., - "messages": ..., - } - ) diff --git a/src/stale/hyperglass/hyperglass/models/config/proxy.py b/src/stale/hyperglass/hyperglass/models/config/proxy.py deleted file mode 100644 index 2e9b29b..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/proxy.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Validate SSH proxy configuration variables.""" - -# Standard Library -import typing as t -from ipaddress import IPv4Address, IPv6Address - -# Third Party -from pydantic import ValidationInfo, field_validator - -# Project -from hyperglass.util import resolve_hostname -from hyperglass.exceptions.private import ConfigError, UnsupportedDevice - -# Local -from ..main import HyperglassModel -from ..util import check_legacy_fields -from .credential import Credential - - -class Proxy(HyperglassModel): - """Validation model for per-proxy config in devices.yaml.""" - - address: t.Union[IPv4Address, IPv6Address, str] - port: int = 22 - credential: Credential - platform: str = "linux_ssh" - - def __init__(self: "Proxy", **kwargs: t.Any) -> None: - """Check for legacy fields.""" - kwargs = check_legacy_fields(model="Proxy", data=kwargs) - super().__init__(**kwargs) - - @property - def _target(self): - return str(self.address) - - @field_validator("address") - def validate_address(cls, value): - """Ensure a hostname is resolvable.""" - - if not isinstance(value, (IPv4Address, IPv6Address)): - if not any(resolve_hostname(value)): - raise ConfigError( - "Proxy '{a}' is not resolvable.", - a=value, - ) - return value - - @field_validator("platform", mode="before") - def validate_type(cls: "Proxy", value: t.Any, info: ValidationInfo) -> str: - """Validate device type.""" - - if value != "linux_ssh": - raise UnsupportedDevice( - "Proxy '{}' uses platform '{}', which is currently unsupported.", - info.data.get("address"), - value, - ) - return value diff --git a/src/stale/hyperglass/hyperglass/models/config/structured.py b/src/stale/hyperglass/hyperglass/models/config/structured.py deleted file mode 100644 index 4373e81..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/structured.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Structured data configuration variables.""" - -# Standard Library -import typing as t - -# Local -from ..main import HyperglassModel - -StructuredCommunityMode = t.Literal["permit", "deny"] -StructuredRPKIMode = t.Literal["router", "external"] - - -class StructuredCommunities(HyperglassModel): - """Control structured data response for BGP communities.""" - - mode: StructuredCommunityMode = "deny" - items: t.List[str] = [] - - -class StructuredRpki(HyperglassModel): - """Control structured data response for RPKI state.""" - - mode: StructuredRPKIMode = "router" - - -class Structured(HyperglassModel): - """Control structured data responses.""" - - communities: StructuredCommunities = StructuredCommunities() - rpki: StructuredRpki = StructuredRpki() diff --git a/src/stale/hyperglass/hyperglass/models/config/web.py b/src/stale/hyperglass/hyperglass/models/config/web.py deleted file mode 100644 index 7916d9e..0000000 --- a/src/stale/hyperglass/hyperglass/models/config/web.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Validate branding configuration variables.""" - -# Standard Library -import typing as t -from pathlib import Path - -# Third Party -from pydantic import Field, HttpUrl, FilePath, ValidationInfo, field_validator, model_validator -from pydantic_extra_types.color import Color - -# Project -from hyperglass.defaults import DEFAULT_HELP, DEFAULT_TERMS -from hyperglass.constants import DNS_OVER_HTTPS, FUNC_COLOR_MAP - -# Local -from ..main import HyperglassModel -from .opengraph import OpenGraph - -DEFAULT_IMAGES = Path(__file__).parent.parent.parent / "images" -DOH_PROVIDERS_PATTERN = "|".join(DNS_OVER_HTTPS.keys()) -PERCENTAGE_PATTERN = r"^([1-9][0-9]?|100)\%?$" - -Percentage = Field(pattern=r"^([1-9][0-9]?|100)\%$") -TitleMode = t.Literal["logo_only", "text_only", "logo_subtitle", "all"] -ColorMode = t.Literal["light", "dark"] -Side = t.Literal["left", "right"] -LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"] - - -class Credit(HyperglassModel): - """Validation model for developer credit.""" - - enable: bool = True - - -class Link(HyperglassModel): - """Validation model for generic link.""" - - title: str - url: HttpUrl - show_icon: bool = True - side: Side = "left" - order: int = 0 - - -class Menu(HyperglassModel): - """Validation model for generic menu.""" - - title: str - content: str - side: Side = "left" - order: int = 0 - - @field_validator("content") - def validate_content(cls: "Menu", value: str) -> str: - """Read content from file if a path is provided.""" - - if len(value) < 260: - path = Path(value) - if path.is_file() and path.exists(): - with path.open("r") as f: - return f.read() - return value - - -class Greeting(HyperglassModel): - """Validation model for greeting modal.""" - - enable: bool = False - file: t.Optional[FilePath] = None - title: str = "Welcome" - button: str = "Continue" - required: bool = False - - @field_validator("file") - def validate_file(cls, value: str, info: ValidationInfo): - """Ensure file is specified if greeting is enabled.""" - if info.data.get("enable") and value is None: - raise ValueError("Greeting is enabled, but no file is specified.") - return value - - -class Logo(HyperglassModel): - """Validation model for logo configuration.""" - - light: FilePath = DEFAULT_IMAGES / "hyperglass-light.svg" - dark: FilePath = DEFAULT_IMAGES / "hyperglass-dark.svg" - favicon: FilePath = DEFAULT_IMAGES / "hyperglass-icon.svg" - width: str = Field(default="50%", pattern=PERCENTAGE_PATTERN) - height: t.Optional[str] = Field(default=None, pattern=PERCENTAGE_PATTERN) - - -class LogoPublic(Logo): - """Public logo configuration.""" - - light_format: str - dark_format: str - - -class Text(HyperglassModel): - """Validation model for params.branding.text.""" - - title_mode: TitleMode = "logo_only" - title: str = Field(default="hyperglass", max_length=32) - subtitle: str = Field(default="Network Looking Glass", max_length=32) - query_location: str = "Location" - query_type: str = "Query Type" - query_target: str = "Target" - fqdn_tooltip: str = "Use {protocol}" # Formatted by Javascript - fqdn_message: str = "Your browser has resolved {fqdn} to" # Formatted by Javascript - fqdn_error: str = "Unable to resolve {fqdn}" # Formatted by Javascript - fqdn_error_button: str = "Try Again" - cache_prefix: str = "Results cached for " - cache_icon: str = "Cached from {time} UTC" # Formatted by Javascript - complete_time: str = "Completed in {seconds}" # Formatted by Javascript - rpki_invalid: str = "Invalid" - rpki_valid: str = "Valid" - rpki_unknown: str = "No ROAs Exist" - rpki_unverified: str = "Not Verified" - no_communities: str = "No Communities" - ip_error: str = "Unable to determine IP Address" - no_ip: str = "No {protocol} Address" - ip_select: str = "Select an IP Address" - ip_button: str = "My IP" - - @field_validator("cache_prefix") - def validate_cache_prefix(cls: "Text", value: str) -> str: - """Ensure trailing whitespace.""" - return " ".join(value.split()) + " " - - -class ThemeColors(HyperglassModel): - """Validation model for theme colors.""" - - black: Color = "#000000" - white: Color = "#ffffff" - dark: Color = "#010101" - light: Color = "#f5f6f7" - gray: Color = "#c1c7cc" - red: Color = "#d84b4b" - orange: Color = "#ff6b35" - yellow: Color = "#edae49" - green: Color = "#35b246" - blue: Color = "#314cb6" - teal: Color = "#35b299" - cyan: Color = "#118ab2" - pink: Color = "#f2607d" - purple: Color = "#8d30b5" - primary: t.Optional[Color] = None - secondary: t.Optional[Color] = None - success: t.Optional[Color] = None - warning: t.Optional[Color] = None - error: t.Optional[Color] = None - danger: t.Optional[Color] = None - - @field_validator(*FUNC_COLOR_MAP.keys(), mode="before") - def validate_colors(cls: "ThemeColors", value: str, info: ValidationInfo) -> str: - """Set default functional color mapping.""" - if value is None: - default_color = FUNC_COLOR_MAP[info.field_name] - value = str(info.data[default_color]) - return value - - def dict(self, *args: t.Any, **kwargs: t.Any) -> t.Dict[str, str]: - """Return dict for colors only.""" - return {k: v.as_hex() for k, v in self.__dict__.items()} - - -class ThemeFonts(HyperglassModel): - """Validation model for theme fonts.""" - - body: str = "Nunito" - mono: str = "Fira Code" - - -class Theme(HyperglassModel): - """Validation model for theme variables.""" - - colors: ThemeColors = ThemeColors() - default_color_mode: t.Optional[ColorMode] = None - fonts: ThemeFonts = ThemeFonts() - - -class DnsOverHttps(HyperglassModel): - """Validation model for DNS over HTTPS resolution.""" - - name: str = "cloudflare" - url: str = "" - - @model_validator(mode="before") - def validate_dns(cls, data: "DnsOverHttps") -> t.Dict[str, str]: - """Assign url field to model based on selected provider.""" - name = data.get("name", "cloudflare") - url = data.get("url", DNS_OVER_HTTPS["cloudflare"]) - if url not in DNS_OVER_HTTPS.values(): - return { - "name": "custom", - "url": url, - } - url = DNS_OVER_HTTPS[name] - return { - "name": name, - "url": url, - } - - -class HighlightPattern(HyperglassModel): - """Validation model for highlight pattern configuration.""" - - pattern: str - label: t.Optional[str] = None - color: str = "primary" - - @field_validator("color") - def validate_color(cls: "HighlightPattern", value: str) -> str: - """Ensure highlight color is a valid theme color.""" - colors = list(ThemeColors.model_fields.keys()) - color_list = "\n - ".join(("", *colors)) - if value not in colors: - raise ValueError( - "{!r} is not a supported color. Must be one of:{!s}".format(value, color_list) - ) - return value - - -class Web(HyperglassModel): - """Validation model for all web/browser-related configuration.""" - - credit: Credit = Credit() - dns_provider: DnsOverHttps = DnsOverHttps() - links: t.Sequence[Link] = [ - Link(title="PeeringDB", url="https://www.peeringdb.com/asn/{primary_asn}") - ] - menus: t.Sequence[Menu] = [ - Menu(title="Terms", content=DEFAULT_TERMS), - Menu(title="Help", content=DEFAULT_HELP), - ] - greeting: Greeting = Greeting() - logo: Logo = Logo() - opengraph: OpenGraph = OpenGraph() - text: Text = Text() - theme: Theme = Theme() - location_display_mode: LocationDisplayMode = "auto" - custom_javascript: t.Optional[FilePath] = None - custom_html: t.Optional[FilePath] = None - highlight: t.List[HighlightPattern] = [] - - -class WebPublic(Web): - """Public web configuration.""" - - logo: LogoPublic diff --git a/src/stale/hyperglass/hyperglass/models/data/__init__.py b/src/stale/hyperglass/hyperglass/models/data/__init__.py deleted file mode 100644 index 02d8f27..0000000 --- a/src/stale/hyperglass/hyperglass/models/data/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Data structure models.""" - -# Standard Library -from typing import Union - -# Local -from .bgp_route import BGPRouteTable - -OutputDataModel = Union[BGPRouteTable] - -__all__ = ( - "BGPRouteTable", - "OutputDataModel", -) diff --git a/src/stale/hyperglass/hyperglass/models/data/bgp_route.py b/src/stale/hyperglass/hyperglass/models/data/bgp_route.py deleted file mode 100644 index a00af4e..0000000 --- a/src/stale/hyperglass/hyperglass/models/data/bgp_route.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Device-Agnostic Parsed Response Data Model.""" - -# Standard Library -import re -import typing as t -from ipaddress import ip_network - -# Third Party -from pydantic import ValidationInfo, field_validator - -# Project -from hyperglass.state import use_state -from hyperglass.external.rpki import rpki_state - -# Local -from ..main import HyperglassModel - -WinningWeight = t.Literal["low", "high"] - - -class BGPRoute(HyperglassModel): - """Post-parsed BGP route.""" - - prefix: str - active: bool - age: int - weight: int - med: int - local_preference: int - as_path: t.List[int] - communities: t.List[str] - next_hop: str - source_as: int - source_rid: str - peer_rid: str - rpki_state: int - - @field_validator("communities") - def validate_communities(cls, value): - """Filter returned communities against configured policy. - - Actions: - permit: only permit matches - deny: only deny matches - """ - - (structured := use_state("params").structured) - - def _permit(comm): - """Only allow matching patterns.""" - valid = False - for pattern in structured.communities.items: - if re.match(pattern, comm): - valid = True - break - return valid - - def _deny(comm): - """Allow any except matching patterns.""" - valid = True - for pattern in structured.communities.items: - if re.match(pattern, comm): - valid = False - break - return valid - - func_map = {"permit": _permit, "deny": _deny} - func = func_map[structured.communities.mode] - - return [c for c in value if func(c)] - - @field_validator("rpki_state") - def validate_rpki_state(cls, value, info: ValidationInfo): - """If external RPKI validation is enabled, get validation state.""" - - (structured := use_state("params").structured) - - if structured.rpki.mode == "router": - # If router validation is enabled, return the value as-is. - return value - - if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. - as_path = info.data.get("as_path", []) - - if len(as_path) == 0: - # If the AS_PATH length is 0, i.e. for an internal route, - # return RPKI Unknown state. - return 3 - # Get last ASN in path - asn = as_path[-1] - - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 - - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) - - return value - - -class BGPRouteTable(HyperglassModel): - """Post-parsed BGP route table.""" - - vrf: str - count: int = 0 - routes: t.List[BGPRoute] - winning_weight: WinningWeight - - def __init__(self, **kwargs): - """Sort routes by prefix after validation.""" - super().__init__(**kwargs) - self.routes = sorted(self.routes, key=lambda r: r.prefix) - - def __add__(self: "BGPRouteTable", other: "BGPRouteTable") -> "BGPRouteTable": - """Merge another BGP table instance with this instance.""" - if isinstance(other, BGPRouteTable): - self.routes = sorted([*self.routes, *other.routes], key=lambda r: r.prefix) - self.count = len(self.routes) - return self diff --git a/src/stale/hyperglass/hyperglass/models/directive.py b/src/stale/hyperglass/hyperglass/models/directive.py deleted file mode 100644 index c88c2c1..0000000 --- a/src/stale/hyperglass/hyperglass/models/directive.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Generic command models.""" - -# Standard Library -import re -import typing as t -from ipaddress import IPv4Network, IPv6Network, ip_network - -# Third Party -from pydantic import Field, FilePath, PrivateAttr, IPvAnyNetwork, field_validator - -# Project -from hyperglass.log import log -from hyperglass.types import Series -from hyperglass.settings import Settings -from hyperglass.exceptions.private import InputValidationError - -# Local -from .main import MultiModel, HyperglassModel, HyperglassUniqueModel -from .fields import Action - -StringOrArray = t.Union[str, t.List[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"] -RuleTypeAttr = t.Literal["ipv4", "ipv6", "pattern", "none"] - - -class Input(HyperglassModel): - """Base input field.""" - - _type: PrivateAttr - description: str - - @property - def is_select(self) -> bool: - """Determine if this field is a select field.""" - return self._type == "select" - - @property - def is_text(self) -> bool: - """Determine if this field is an input/text field.""" - return self._type == "text" - - -class Text(Input): - """Text/input field model.""" - - _type: PrivateAttr = PrivateAttr("text") - validation: t.Optional[str] = None - - -class Option(HyperglassModel): - """Select option model.""" - - name: t.Optional[str] = None - description: t.Optional[str] = None - value: str - - -class Select(Input): - """Select field model.""" - - _type: PrivateAttr = PrivateAttr("select") - options: t.List[Option] - - -class Rule(HyperglassModel): - """Base rule.""" - - _type: RuleTypeAttr = "none" - _passed: PassedValidation = PrivateAttr(None) - condition: Condition - action: Action = "permit" - commands: t.List[str] = Field([], alias="command") - - @field_validator("commands", mode="before") - def validate_commands(cls, value: t.Union[str, t.List[str]]) -> t.List[str]: - """Ensure commands is a list.""" - if isinstance(value, str): - return [value] - return value - - def validate_target(self, target: str, *, multiple: bool) -> bool: - """Validate a query target (Placeholder signature).""" - raise NotImplementedError( - f"{self._type} rule does not implement a 'validate_target()' method" - ) - - -class RuleWithIP(Rule): - """Base IP-based rule.""" - - condition: t.Union[IPv4Network, IPv6Network] - allow_reserved: bool = False - allow_unspecified: bool = False - allow_loopback: bool = False - ge: int - le: int - - def __init__(self, **kw) -> None: - super().__init__(**kw) - if self.condition.network_address.version == 4: - self._type = "ipv4" - else: - self._type = "ipv6" - - def membership(self, target: IPvAnyNetwork, network: IPvAnyNetwork) -> bool: - """Check if IP address belongs to network.""" - _log = log.bind(target=str(target), network=str(network)) - _log.debug("Checking target membership") - if ( - network.network_address <= target.network_address - and network.broadcast_address >= target.broadcast_address - ): - _log.debug("Target membership verified") - return True - return False - - def in_range(self, target: IPvAnyNetwork) -> bool: - """Verify if target prefix length is within ge/le threshold.""" - if target.prefixlen <= self.le and target.prefixlen >= self.ge: - log.bind(target=str(target), range=f"{self.ge!s}-{self.le!s}").debug( - "Target is in range" - ) - return True - - return False - - def validate_target(self, target: str, *, multiple: bool) -> bool: - """Validate an IP address target against this rule's conditions.""" - - if isinstance(target, t.List): - if len(target) > 1: - self._passed = False - raise InputValidationError(error="Target must be a single value", target=target) - target = target[0] - - try: - # Attempt to use IP object factory to create an IP address object - valid_target = ip_network(target) - - except ValueError as err: - raise InputValidationError(error=str(err), target=target) from err - - if valid_target.version != self.condition.version: - log.bind(target=str(target), condition=str(self.condition)).debug( - "Mismatching IP version" - ) - return False - - is_member = self.membership(valid_target, self.condition) - in_range = self.in_range(valid_target) - - if all((is_member, in_range, self.action == "permit")): - self._passed = True - return True - - if is_member and not in_range: - self._passed = False - raise InputValidationError( - error="Prefix-length is not within range {ge}-{le}", - target=target, - ge=self.ge, - le=self.le, - ) - - if is_member and self.action == "deny": - self._passed = False - raise InputValidationError( - error="Member of denied network '{network}'", - target=target, - network=str(self.condition), - ) - - return False - - -class RuleWithIPv4(RuleWithIP): - """A rule by which to evaluate an IPv4 target.""" - - _type: RuleTypeAttr = "ipv4" - condition: IPv4Network - ge: int = Field(0, ge=0, le=32) - le: int = Field(32, ge=0, le=32) - - -class RuleWithIPv6(RuleWithIP): - """A rule by which to evaluate an IPv6 target.""" - - _type: RuleTypeAttr = "ipv6" - condition: IPv6Network - ge: int = Field(0, ge=0, le=128) - le: int = Field(128, ge=0, le=128) - - -class RuleWithPattern(Rule): - """A rule validated by a regular expression pattern.""" - - _type: RuleTypeAttr = "pattern" - condition: str - - def validate_target(self, target: str, *, multiple: bool) -> str: # noqa: C901 - """Validate a string target against configured regex patterns.""" - - def validate_single_value(value: str) -> t.Union[bool, BaseException]: - if self.condition == "*": - pattern = re.compile(".+", re.IGNORECASE) - else: - pattern = re.compile(self.condition, re.IGNORECASE) - is_match = pattern.match(value) - - if is_match and self.action == "permit": - return True - if is_match and self.action == "deny": - return InputValidationError(target=value, error="Denied") - return False - - if isinstance(target, t.List): - for result in (validate_single_value(v) for v in target): - if isinstance(result, BaseException): - self._passed = False - raise result - if result is False: - self._passed = False - return result - self._passed = True - return True - - result = validate_single_value(target) - - if isinstance(result, BaseException): - self._passed = False - raise result - self._passed = result - return result - - -class RuleWithoutValidation(Rule): - """A rule with no validation.""" - - _type: RuleTypeAttr = "none" - condition: None = None - - def validate_target(self, target: str, *, multiple: bool) -> t.Literal[True]: - """Don't validate a target. Always returns `True`.""" - self._passed = True - return True - - -RuleType = t.Union[ - RuleWithIPv4, - RuleWithIPv6, - RuleWithPattern, - RuleWithoutValidation, -] - - -class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): - """A directive contains commands that can be run on a device, as long as defined rules are met.""" - - _hyperglass_builtin: bool = PrivateAttr(False) - - id: str - name: str - rules: t.List[RuleType] = [RuleWithoutValidation()] - field: t.Union[Text, Select, None] - info: t.Optional[FilePath] = None - plugins: t.List[str] = [] - table_output: t.Optional[str] = None - groups: t.List[str] = [] - multiple: bool = False - multiple_separator: str = " " - - @field_validator("rules", mode="before") - @classmethod - def validate_rules(cls, rules: t.List[t.Dict[str, t.Any]]): - """Initialize the correct rule type based on condition value.""" - out_rules: t.List[RuleType] = [] - for rule in rules: - if isinstance(rule, dict): - condition = rule.get("condition") - if condition is None: - out_rules.append(RuleWithoutValidation(**rule)) - else: - try: - condition_net = ip_network(condition) - if condition_net.version == 4: - out_rules.append(RuleWithIPv4(**rule)) - if condition_net.version == 6: - out_rules.append(RuleWithIPv6(**rule)) - except ValueError: - out_rules.append(RuleWithPattern(**rule)) - elif isinstance(rule, Rule): - out_rules.append(rule) - return out_rules - - def validate_target(self, target: str) -> bool: - """Validate a target against all configured rules.""" - for rule in self.rules: - valid = rule.validate_target(target, multiple=self.multiple) - if valid is True: - return True - continue - raise InputValidationError(error="No matched validation rules", target=target) - - @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: - return "text" - return None - - @field_validator("plugins") - def validate_plugins(cls: "Directive", plugins: t.List[str]) -> t.List[str]: - """Validate and register configured plugins.""" - plugin_dir = Settings.app_path / "plugins" - - if plugin_dir.exists(): - # Path objects whose file names match configured file names, should work - # whether or not file extension is specified. - matching_plugins = ( - f - for f in plugin_dir.iterdir() - if f.name.split(".")[0] in (p.split(".")[0] for p in plugins) - ) - return [str(f) for f in matching_plugins] - return [] - - def frontend(self: "Directive") -> t.Dict[str, t.Any]: - """Prepare a representation of the directive for the UI.""" - - value = { - "id": self.id, - "name": self.name, - "field_type": self.field_type, - "groups": self.groups, - "description": self.field.description if self.field is not None else '', - "info": None, - } - - if self.info is not None: - with self.info.open() as md: - value["info"] = md.read() - - 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 - - -class BuiltinDirective(Directive, unique_by=("id", "table_output", "platforms")): - """Natively-supported directive.""" - - _hyperglass_builtin: bool = PrivateAttr(True) - platforms: Series[str] = [] - - -DirectiveT = t.Union[BuiltinDirective, Directive] - - -class Directives(MultiModel[Directive], model=Directive, unique_by="id"): - """Collection of directives.""" - - def device_builtins(self, *, platform: str, table_output: bool): - """Get builtin directives for a device.""" - - return Directives( - *( - self.table_if_available(directive) if table_output else directive # noqa: IF100 GFY - for directive in self - if directive._hyperglass_builtin is True - and platform in getattr(directive, "platforms", ()) - ) - ) - - def table_if_available(self, directive: "Directive") -> "Directive": - """Get the table-output variant of a directive if it exists.""" - for _directive in self: - if _directive.id == directive.table_output: - return _directive - return directive - - @classmethod - def new(cls, /, *raw_directives: t.Dict[str, t.Any]) -> "Directives": - """Create a new Directives collection from raw directive configurations.""" - directives = ( - Directive(id=name, **directive) - for raw_directive in raw_directives - for name, directive in raw_directive.items() - ) - return Directives(*directives) diff --git a/src/stale/hyperglass/hyperglass/models/fields.py b/src/stale/hyperglass/hyperglass/models/fields.py deleted file mode 100644 index 6314a91..0000000 --- a/src/stale/hyperglass/hyperglass/models/fields.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Custom Pydantic Fields/Types.""" - -# Standard Library -import re -import typing as t - -# Third Party -from pydantic import AfterValidator, BeforeValidator - -IntFloat = t.TypeVar("IntFloat", int, float) -J = t.TypeVar("J") - -SupportedDriver = t.Literal["netmiko", "hyperglass_agent"] -HttpAuthMode = t.Literal["basic", "api_key"] -HttpProvider = t.Literal["msteams", "slack", "generic"] -LogFormat = t.Literal["text", "json"] -Primitives = t.Union[None, float, int, bool, str] -JsonValue = t.Union[J, t.Sequence[J], t.Dict[str, J]] -ActionValue = t.Literal["permit", "deny"] -HttpMethodValue = t.Literal[ - "CONNECT", - "DELETE", - "GET", - "HEAD", - "OPTIONS", - "PATCH", - "POST", - "PUT", - "TRACE", -] - - -def validate_uri(value: str) -> str: - """Ensure URI string contains a leading forward-slash.""" - uri_regex = re.compile(r"^(\/.*)$") - match = uri_regex.fullmatch(value) - if not match: - raise ValueError("Invalid format. A URI must begin with a forward slash, e.g. '/example'") - return match.group() - - -def validate_action(value: str) -> ActionValue: - """Ensure action is an allowed value or acceptable alias.""" - permits = ("permit", "allow", "accept") - denies = ("deny", "block", "reject") - value = value.strip().lower() - if value in permits: - return "permit" - if value in denies: - return "deny" - - raise ValueError("Action must be one of '{}'".format(", ".join((*permits, *denies)))) - - -AnyUri = t.Annotated[str, AfterValidator(validate_uri)] -Action = t.Annotated[ActionValue, AfterValidator(validate_action)] -HttpMethod = t.Annotated[HttpMethodValue, BeforeValidator(str.upper)] diff --git a/src/stale/hyperglass/hyperglass/models/main.py b/src/stale/hyperglass/hyperglass/models/main.py deleted file mode 100644 index d4fa3ae..0000000 --- a/src/stale/hyperglass/hyperglass/models/main.py +++ /dev/null @@ -1,358 +0,0 @@ -"""Data models used throughout hyperglass.""" - -# Standard Library - -# Standard Library -import re -import json -import typing as t -from pathlib import Path - -# Third Party -from pydantic import HttpUrl, BaseModel, RootModel, ConfigDict, PrivateAttr - -# Project -from hyperglass.log import log -from hyperglass.util import compare_init, snake_to_camel, repr_from_attrs -from hyperglass.types import Series - -MultiModelT = t.TypeVar("MultiModelT", bound=BaseModel) - -PathTypeT = t.TypeVar("PathTypeT") - - -def alias_generator(field: str) -> str: - """Remove unsupported characters from field names. - - Converts any "desirable" separators to underscore, then removes all - characters that are unsupported in Python class variable names. - Also removes leading numbers underscores. - """ - _replaced = re.sub(r"[\-|\.|\@|\~|\:\/|\s]", "_", field) - _scrubbed = "".join(re.findall(r"([a-zA-Z]\w+|\_+)", _replaced)) - snake_field = _scrubbed.lower() - return snake_to_camel(snake_field) - - -class HyperglassModel(BaseModel): - """Base model for all hyperglass configuration models.""" - - model_config = ConfigDict( - extra="forbid", - json_encoders={HttpUrl: lambda v: str(v), Path: str}, - populate_by_name=True, - validate_assignment=True, - validate_default=True, - alias_generator=alias_generator, - ) - - def convert_paths(self, value: t.Type[PathTypeT]) -> PathTypeT: - """Change path to relative to app_path. - - This is required when running hyperglass in a container so that - the original app_path on the host system is not passed through - to the container. - """ - # Project - from hyperglass.settings import Settings - - if isinstance(value, Path): - if Settings.container: - return Settings.default_app_path.joinpath( - *( - p - for p in value.parts - if p not in Settings.original_app_path.absolute().parts - ) - ) - - if isinstance(value, str) and str(Settings.original_app_path.absolute()) in value: - if Settings.container: - path = Path(value) - return str( - Settings.default_app_path.joinpath( - *( - p - for p in path.parts - if p not in Settings.original_app_path.absolute().parts - ) - ) - ) - - if isinstance(value, t.Tuple): - return tuple(self.convert_paths(v) for v in value) - if isinstance(value, t.List): - return [self.convert_paths(v) for v in value] - if isinstance(value, t.Generator): - return (self.convert_paths(v) for v in value) - if isinstance(value, t.Dict): - return {k: self.convert_paths(v) for k, v in value.items()} - return value - - def _repr_from_attrs(self, attrs: Series[str]) -> str: - """Alias to `hyperglass.util:repr_from_attrs` in the context of this model.""" - return repr_from_attrs(self, attrs) - - def export_json(self, *args, **kwargs): - """Return instance as JSON.""" - - export_kwargs = {"by_alias": False, "exclude_unset": False} - - for key in kwargs.keys(): - export_kwargs.pop(key, None) - - return self.model_dump_json(*args, **export_kwargs, **kwargs) - - def export_dict(self, *args, **kwargs): - """Return instance as dictionary.""" - - export_kwargs = {"by_alias": False, "exclude_unset": False} - - for key in kwargs.keys(): - export_kwargs.pop(key, None) - - return self.model_dump(*args, **export_kwargs, **kwargs) - - def export_yaml(self, *args, **kwargs): - """Return instance as YAML.""" - - # Standard Library - import json - - # Third Party - import yaml - - export_kwargs = { - "by_alias": kwargs.pop("by_alias", False), - "exclude_unset": kwargs.pop("exclude_unset", False), - } - - return yaml.safe_dump(json.loads(self.export_json(**export_kwargs)), *args, **kwargs) - - -class HyperglassUniqueModel(HyperglassModel): - """hyperglass model that is unique by its `id` field.""" - - _unique_fields: t.ClassVar[Series[str]] = () - - def __init_subclass__(cls, *, unique_by: Series[str], **kw: t.Any) -> None: - """Assign unique fields to class.""" - cls._unique_fields = tuple(unique_by) - return super().__init_subclass__(**kw) - - def __eq__(self: "HyperglassUniqueModel", other: "HyperglassUniqueModel") -> bool: - """Other model is equal to this model.""" - if not isinstance(other, self.__class__): - return False - if hash(self) == hash(other): - return True - return False - - def __ne__(self: "HyperglassUniqueModel", other: "HyperglassUniqueModel") -> bool: - """Other model is not equal to this model.""" - return not self.__eq__(other) - - def __hash__(self: "HyperglassUniqueModel") -> int: - """Create a hashed representation of this model's name.""" - fields = dict(zip(self._unique_fields, (getattr(self, f) for f in self._unique_fields))) - return hash(json.dumps(fields)) - - -class HyperglassModelWithId(HyperglassModel): - """hyperglass model that is unique by its `id` field.""" - - id: str - - def __eq__(self: "HyperglassModelWithId", other: "HyperglassModelWithId") -> bool: - """Other model is equal to this model.""" - if not isinstance(other, self.__class__): - return False - if hasattr(other, "id"): - return other and self.id == other.id - return False - - def __ne__(self: "HyperglassModelWithId", other: "HyperglassModelWithId") -> bool: - """Other model is not equal to this model.""" - return not self.__eq__(other) - - def __hash__(self: "HyperglassModelWithId") -> int: - """Create a hashed representation of this model's name.""" - return hash(self.id) - - -class MultiModel(RootModel[MultiModelT]): - """Extension of HyperglassModel for managing multiple models as a list.""" - - model_config = ConfigDict( - validate_default=True, - validate_assignment=True, - ) - - model: t.ClassVar[MultiModelT] - unique_by: t.ClassVar[str] - _model_name: t.ClassVar[str] = "MultiModel" - - root: t.List[MultiModelT] = [] - _count: int = PrivateAttr() - - def __init__(self, *items: t.Union[MultiModelT, t.Dict[str, t.Any]]) -> None: - """Validate items.""" - for cls_var in ("model", "unique_by"): - if getattr(self, cls_var, None) is None: - raise AttributeError(f"MultiModel is missing class variable '{cls_var}'") - valid = self._valid_items(*items) - super().__init__(root=valid) - self._count = len(self.root) - - def __init_subclass__(cls, **kw: t.Any) -> None: - """Add class variables from keyword arguments.""" - model = kw.pop("model", None) - cls.model = model - cls.unique_by = kw.pop("unique_by", None) - cls._model_name = getattr(model, "__name__", "MultiModel") - super().__init_subclass__() - - def __repr__(self) -> str: - """Represent model.""" - return repr_from_attrs(self, ["_count", "unique_by", "_model_name"], strip="_") - - def __iter__(self) -> t.Iterator[MultiModelT]: - """Iterate items.""" - return iter(self.root) - - def __getitem__(self, value: t.Union[int, str]) -> MultiModelT: - """Get an item by its `unique_by` property.""" - if not isinstance(value, (str, int)): - raise TypeError( - "Value of {}.{!s} should be a string or integer. Got {!r} ({!s})".format( - self.__class__.__name__, self.unique_by, value, type(value) - ) - ) - if isinstance(value, int): - return self.root[value] - - for item in self: - if hasattr(item, self.unique_by) and getattr(item, self.unique_by) == value: - return item - raise IndexError( - "No match found for {!s}.{!s}={!r}".format( - self.model.__class__.__name__, self.unique_by, value - ), - ) - - def __add__(self, other: MultiModelT) -> MultiModelT: - """Merge another MultiModel with this one. - - Note: If you're subclassing `HyperglassMultiModel` and overriding `__init__`, you need to - override this too. - """ - valid = all( - ( - isinstance(other, self.__class__), - hasattr(other, "model"), - getattr(other, "model", None) == self.model, - ), - ) - if not valid: - raise TypeError(f"Cannot add {other!r} to {self.__class__.__name__}") - merged = self._merge_with(*other, unique_by=self.unique_by) - - if compare_init(self.__class__, other.__class__): - return self.__class__(*merged) - raise TypeError( - f"{self.__class__.__name__} and {other.__class__.__name__} have different `__init__` " - "signatures. You probably need to override `MultiModel.__add__`" - ) - - def __len__(self) -> int: - """Get number of items.""" - return len(self.root) - - @property - def ids(self) -> t.Tuple[t.Any, ...]: - """Get values of all items by `unique_by` property.""" - return tuple(sorted(getattr(item, self.unique_by) for item in self)) - - @property - def count(self) -> int: - """Access item count.""" - return self._count - - @classmethod - def create(cls, name: str, *, model: MultiModelT, unique_by: str) -> "MultiModel": - """Create a MultiModel.""" - new = type(name, (cls,), cls.__dict__) - new.model = model - new.unique_by = unique_by - new._model_name = getattr(model, "__name__", "MultiModel") - return new - - def _valid_items( - self, *to_validate: t.List[t.Union[MultiModelT, t.Dict[str, t.Any]]] - ) -> t.List[MultiModelT]: - items = [ - item - for item in to_validate - if any( - ( - (isinstance(item, self.model) and hasattr(item, self.unique_by)), - (isinstance(item, t.Dict) and self.unique_by in item), - ), - ) - ] - for index, item in enumerate(items): - if isinstance(item, t.Dict): - items[index] = self.model(**item) - return items - - def _merge_with(self, *items, unique_by: t.Optional[str] = None) -> Series[MultiModelT]: - to_add = self._valid_items(*items) - if unique_by is not None: - unique_by_values = { - getattr(obj, unique_by) for obj in (*self, *to_add) if hasattr(obj, unique_by) - } - unique_by_objects = { - v: o - for v in unique_by_values - for o in (*self, *to_add) - if getattr(o, unique_by) == v - } - return tuple(unique_by_objects.values()) - return (*self.root, *to_add) - - def filter(self, *properties: str) -> MultiModelT: - """Get only items with `unique_by` properties matching values in `properties`.""" - return self.__class__( - *(item for item in self if getattr(item, self.unique_by, None) in properties) - ) - - def matching(self, *unique: str) -> MultiModelT: - """Get a new instance containing partial matches from `accessors`.""" - - def matches(*searches: str) -> t.Generator[MultiModelT, None, None]: - """Get any matching items by unique_by property. - - For example, if `unique` is `('one', 'two')`, and `Model.<unique_by>` is `'one'`, - `Model` is yielded. - """ - for search in searches: - pattern = re.compile(rf".*{search}.*", re.IGNORECASE) - for item in self: - if pattern.match(getattr(item, self.unique_by)): - yield item - - return self.__class__(*matches(*unique)) - - def add(self, *items, unique_by: t.Optional[str] = None) -> None: - """Add an item to the model.""" - new = self._merge_with(*items, unique_by=unique_by) - self.root = new - self._count = len(self.root) - for item in new: - log.debug( - "Added {} '{!s}' to {}".format( - item.__class__.__name__, - getattr(item, self.unique_by), - self.__class__.__name__, - ) - ) diff --git a/src/stale/hyperglass/hyperglass/models/parsing/__init__.py b/src/stale/hyperglass/hyperglass/models/parsing/__init__.py deleted file mode 100644 index 9ce8b52..0000000 --- a/src/stale/hyperglass/hyperglass/models/parsing/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Data models for parsed responses.""" diff --git a/src/stale/hyperglass/hyperglass/models/parsing/arista_eos.py b/src/stale/hyperglass/hyperglass/models/parsing/arista_eos.py deleted file mode 100644 index 6f41ad1..0000000 --- a/src/stale/hyperglass/hyperglass/models/parsing/arista_eos.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Data Models for Parsing Arista JSON Response.""" - -# Standard Library -import typing as t -from datetime import datetime - -# Third Party -from pydantic import ConfigDict - -# Project -from hyperglass.log import log -from hyperglass.models.data import BGPRouteTable - -# Local -from ..main import HyperglassModel - -RPKI_STATE_MAP = { - "invalid": 0, - "valid": 1, - "notFound": 2, - "notValidated": 3, -} - -WINNING_WEIGHT = "high" - - -def _alias_generator(field: str) -> str: - caps = "".join(x for x in field.title() if x.isalnum()) - return caps[0].lower() + caps[1:] - - -class _AristaBase(HyperglassModel): - """Base Model for Arista validation.""" - - model_config = ConfigDict(extra="ignore", alias_generator=_alias_generator) - - -class AristaAsPathEntry(_AristaBase): - """Validation model for Arista asPathEntry.""" - - as_path_type: str = "External" - as_path: t.Optional[str] = "" - - -class AristaPeerEntry(_AristaBase): - """Validation model for Arista peerEntry.""" - - peer_router_id: str - peer_addr: str - - -class AristaRouteType(_AristaBase): - """Validation model for Arista routeType.""" - - origin: str - suppressed: bool - valid: bool - active: bool - origin_validity: t.Optional[str] = "notVerified" - - -class AristaRouteDetail(_AristaBase): - """Validation for Arista routeDetail.""" - - origin: str - label_stack: t.List = [] - ext_community_list: t.List[str] = [] - ext_community_list_raw: t.List[t.Union[str, int]] = [] - community_list: t.List[str] = [] - large_community_list: t.List[str] = [] - - -class AristaRoutePath(_AristaBase): - """Validation model for Arista bgpRoutePaths.""" - - as_path_entry: AristaAsPathEntry - med: int = 0 - local_preference: int - weight: int - peer_entry: AristaPeerEntry - reason_not_bestpath: str - timestamp: int = int(datetime.utcnow().timestamp()) - next_hop: str - route_type: AristaRouteType - route_detail: t.Optional[AristaRouteDetail] - - -class AristaRouteEntry(_AristaBase): - """Validation model for Arista bgpRouteEntries.""" - - total_paths: int = 0 - bgp_advertised_peer_groups: t.Dict = {} - mask_length: int - bgp_route_paths: t.List[AristaRoutePath] = [] - - -class AristaBGPTable(_AristaBase): - """Validation model for Arista bgpRouteEntries data.""" - - router_id: str - vrf: str - bgp_route_entries: t.Dict[str, AristaRouteEntry] - # The raw value is really a string, but `int` will convert it. - asn: int - - @staticmethod - def _get_route_age(timestamp: int) -> int: - now = datetime.utcnow() - now_timestamp = int(now.timestamp()) - return now_timestamp - timestamp - - @staticmethod - def _get_as_path(as_path: str) -> t.List[str]: - if as_path == "": - return [] - return [int(p) for p in as_path.split() if p.isdecimal()] - - def bgp_table(self: "AristaBGPTable") -> "BGPRouteTable": - """Convert the Arista-formatted fields to standard parsed data model.""" - routes = [] - count = 0 - for prefix, entries in self.bgp_route_entries.items(): - count += entries.total_paths - - for route in entries.bgp_route_paths: - as_path = self._get_as_path(route.as_path_entry.as_path) - rpki_state = RPKI_STATE_MAP.get(route.route_type.origin_validity, 3) - - # BGP AS Path and BGP Community queries do not include the routeDetail - # block. Therefore, we must verify it exists before including its data. - communities = [] - if route.route_detail is not None: - communities = route.route_detail.community_list - - # iBGP paths contain an empty AS_PATH array. If the AS_PATH is empty, we - # set the source_as to the router's local-as. - source_as = self.asn - if len(as_path) != 0: - source_as = as_path[0] - - routes.append( - { - "prefix": prefix, - "active": route.route_type.active, - "age": self._get_route_age(route.timestamp), - "weight": route.weight, - "med": route.med, - "local_preference": route.local_preference, - "as_path": as_path, - "communities": communities, - "next_hop": route.next_hop, - "source_as": source_as, - "source_rid": route.peer_entry.peer_router_id, - "peer_rid": route.peer_entry.peer_router_id, - "rpki_state": rpki_state, - } - ) - - serialized = BGPRouteTable( - vrf=self.vrf, - count=count, - routes=routes, - winning_weight=WINNING_WEIGHT, - ) - - log.bind(platform="arista_eos", response=repr(serialized)).debug("Serialized response") - return serialized diff --git a/src/stale/hyperglass/hyperglass/models/parsing/frr.py b/src/stale/hyperglass/hyperglass/models/parsing/frr.py deleted file mode 100644 index dacd82c..0000000 --- a/src/stale/hyperglass/hyperglass/models/parsing/frr.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Data Models for Parsing FRRouting JSON Response.""" - -# Standard Library -import typing as t -from datetime import datetime - -# Third Party -from pydantic import ConfigDict, model_validator - -# Project -from hyperglass.log import log -from hyperglass.models.data import BGPRouteTable - -# Local -from ..main import HyperglassModel - -FRRPeerType = t.Literal["internal", "external", "confed-internal", "confed-external"] - - -def _alias_generator(field): - components = field.split("_") - return components[0] + "".join(x.title() for x in components[1:]) - - -class _FRRBase(HyperglassModel): - model_config = ConfigDict(alias_generator=_alias_generator, extra="ignore") - - -class FRRNextHop(_FRRBase): - """FRR Next Hop Model.""" - - ip: str - afi: str - metric: int - accessible: bool - used: bool - - -class FRRPeer(_FRRBase): - """FRR Peer Model.""" - - peer_id: str - router_id: str - type: FRRPeerType - - -class FRRPath(_FRRBase): - """FRR Path Model.""" - - aspath: t.List[int] - 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 - weight: int = 0 - valid: bool - last_update: int - bestpath: bool - community: t.List[str] - nexthops: t.List[FRRNextHop] - peer: FRRPeer - - @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"] - 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 FRRBGPTable(_FRRBase): - """FRR Route Model.""" - - prefix: str - paths: t.List[FRRPath] = [] - - def bgp_table(self): - """Convert the FRR-specific fields to standard parsed data model.""" - - # TODO: somehow, get the actual VRF - vrf = "default" - - routes = [] - for route in self.paths: - now = datetime.utcnow().timestamp() - then = datetime.utcfromtimestamp(route.last_update).timestamp() - age = int(now - then) - routes.append( - { - "prefix": self.prefix, - "active": route.bestpath, - "age": age, - "weight": route.weight, - "med": route.med, - "local_preference": route.loc_prf, - "as_path": route.aspath, - "communities": route.community, - "next_hop": route.nexthops[0].ip, - "source_as": route.aggregator_as, - "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, - } - ) - - serialized = BGPRouteTable( - vrf=vrf, - count=len(routes), - routes=routes, - winning_weight="high", - ) - - log.bind(platform="frr", response=repr(serialized)).debug("Serialized response") - return serialized diff --git a/src/stale/hyperglass/hyperglass/models/parsing/juniper.py b/src/stale/hyperglass/hyperglass/models/parsing/juniper.py deleted file mode 100644 index f4f999c..0000000 --- a/src/stale/hyperglass/hyperglass/models/parsing/juniper.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Data Models for Parsing Juniper XML Response.""" - -# Standard Library -import typing as t - -# Third Party -from pydantic import ConfigDict, field_validator, model_validator - -# Project -from hyperglass.log import log -from hyperglass.util import deep_convert_keys -from hyperglass.models.data.bgp_route import BGPRouteTable - -# Local -from ..main import HyperglassModel - -RPKI_STATE_MAP = { - "invalid": 0, - "valid": 1, - "unknown": 2, - "unverified": 3, -} - - -class JuniperBase(HyperglassModel, extra="ignore"): - """Base Juniper model.""" - - def __init__(self, **kwargs: t.Any) -> None: - """Convert all `-` keys to `_`. - - Default camelCase alias generator will still be used. - """ - rebuilt = deep_convert_keys(kwargs, lambda k: k.replace("-", "_")) - super().__init__(**rebuilt) - - -class JuniperRouteTableEntry(JuniperBase): - """Parse Juniper rt-entry data.""" - - model_config = ConfigDict(validate_assignment=False) - - active_tag: bool - preference: int - age: int - local_preference: int - metric: int = 0 - as_path: t.List[int] = [] - validation_state: int = 3 - next_hop: str - peer_rid: str - peer_as: int - source_as: int - source_rid: str - communities: t.List[str] = None - - @model_validator(mode="before") - def validate_optional_flags(cls, values: t.Dict[str, t.Any]): - """Flatten & rename keys prior to validation.""" - next_hops = [] - nh = None - - # Handle Juniper's 'Router' Next Hop Type - if "nh" in values: - nh = values.pop("nh") - - # Handle Juniper's 'Indirect' Next Hop Type - if "protocol_nh" in values: - nh = values.pop("protocol_nh") - - # Force the next hops to be a list - if isinstance(nh, t.Dict): - nh = [nh] - - if nh is not None: - next_hops.extend(nh) - - # Extract the 'to:' value from the next-hop - selected_next_hop = "" - for hop in next_hops: - if "selected_next_hop" in hop: - selected_next_hop = hop.get("to", "") - break - if hop.get("to") is not None: - selected_next_hop = hop["to"] - break - - values["next_hop"] = selected_next_hop - - _path_attr = values.get("bgp_path_attributes", {}) - _path_attr_agg = _path_attr.get("attr_aggregator", {}).get("attr_value", {}) - values["as_path"] = _path_attr.get("attr_as_path_effective", {}).get("attr_value", "") - values["source_as"] = _path_attr_agg.get("aggr_as_number", 0) - values["source_rid"] = _path_attr_agg.get("aggr_router_id", "") - values["peer_rid"] = values.get("peer_id", "") - - return values - - @field_validator("validation_state", mode="before") - def validate_rpki_state(cls, value): - """Convert string RPKI state to standard integer mapping.""" - return RPKI_STATE_MAP.get(value, 3) - - @field_validator("active_tag", mode="before") - def validate_active_tag(cls, value): - """Convert active-tag from string/null to boolean.""" - if value == "*": - value = True - else: - value = False - return value - - @field_validator("age", mode="before") - def validate_age(cls, value): - """Get age as seconds.""" - if not isinstance(value, dict): - try: - value = int(value) - except ValueError as err: - raise ValueError(f"Age field is in an unexpected format. Got: {value}") from err - else: - value = value.get("@junos:seconds", 0) - return int(value) - - @field_validator("as_path", mode="before") - def validate_as_path(cls, value): - """Remove origin flags from AS_PATH.""" - disallowed = ("E", "I", "?") - return [int(a) for a in value.split() if a not in disallowed] - - @field_validator("communities", mode="before") - def validate_communities(cls, value): - """Flatten community list.""" - if value is not None: - flat = value.get("community", []) - else: - flat = [] - return flat - - -class JuniperRouteTable(JuniperBase): - """Validation model for Juniper rt data.""" - - rt_destination: str - rt_prefix_length: int - rt_entry_count: int - rt_announced_count: int - rt_entry: t.List[JuniperRouteTableEntry] - - @field_validator("rt_entry_count", mode="before") - def validate_entry_count(cls, value): - """Flatten & convert entry-count to integer.""" - return int(value.get("#text")) - - -class JuniperBGPTable(JuniperBase): - """Validation model for route-table data.""" - - table_name: str - destination_count: int - total_route_count: int - active_route_count: int - hidden_route_count: int - rt: t.List[JuniperRouteTable] - - def bgp_table(self: "JuniperBGPTable") -> "BGPRouteTable": - """Convert the Juniper-specific fields to standard parsed data model.""" - vrf_parts = self.table_name.split(".") - if len(vrf_parts) == 2: - vrf = "default" - else: - vrf = vrf_parts[0] - - routes = [] - count = 0 - for table in self.rt: - count += table.rt_entry_count - prefix = "/".join(str(i) for i in (table.rt_destination, table.rt_prefix_length)) - for route in table.rt_entry: - routes.append( - { - "prefix": prefix, - "active": route.active_tag, - "age": route.age, - "weight": route.preference, - "med": route.metric, - "local_preference": route.local_preference, - "as_path": route.as_path, - "communities": route.communities, - "next_hop": route.next_hop, - "source_as": route.source_as, - "source_rid": route.source_rid, - "peer_rid": route.peer_rid, - "rpki_state": route.validation_state, - } - ) - - serialized = BGPRouteTable(vrf=vrf, count=count, routes=routes, winning_weight="low") - log.bind(platform="juniper", response=repr(serialized)).debug("Serialized response") - return serialized diff --git a/src/stale/hyperglass/hyperglass/models/system.py b/src/stale/hyperglass/hyperglass/models/system.py deleted file mode 100644 index 0494526..0000000 --- a/src/stale/hyperglass/hyperglass/models/system.py +++ /dev/null @@ -1,176 +0,0 @@ -"""hyperglass System Settings model.""" - -# Standard Library -import typing as t -from pathlib import Path -from ipaddress import ip_address - -# Third Party -from pydantic import ( - FilePath, - RedisDsn, - SecretStr, - DirectoryPath, - IPvAnyAddress, - ValidationInfo, - field_validator, -) -from pydantic_settings import BaseSettings, SettingsConfigDict - -# Project -from hyperglass.util import at_least, cpu_count - -if t.TYPE_CHECKING: - # Third Party - from rich.console import Console, RenderResult, ConsoleOptions - -ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]] - -_default_app_path = Path("/etc/hyperglass") - - -class HyperglassSettings(BaseSettings): - """hyperglass system settings, required to start hyperglass.""" - - model_config = SettingsConfigDict(env_prefix="hyperglass_") - - config_file_names: t.ClassVar[t.Tuple[str, ...]] = ("config", "devices", "directives") - default_app_path: t.ClassVar[Path] = _default_app_path - original_app_path: Path = _default_app_path - - debug: bool = False - dev_mode: bool = False - disable_ui: bool = False - app_path: DirectoryPath = _default_app_path - redis_host: str = "localhost" - redis_password: t.Optional[SecretStr] = None - redis_db: int = 1 - redis_dsn: RedisDsn = None - host: IPvAnyAddress = None - port: int = 8001 - ca_cert: t.Optional[FilePath] = None - container: bool = False - - def __init__(self, **kwargs) -> None: - """Create hyperglass Settings instance.""" - super().__init__(**kwargs) - if self.container: - self.app_path = self.default_app_path - - def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult": - """Render a Rich table representation of hyperglass settings.""" - # Third Party - from rich.panel import Panel - from rich.style import Style - from rich.table import Table, box - from rich.pretty import Pretty - - table = Table(box=box.MINIMAL, border_style="subtle") - table.add_column("Environment Variable", style=Style(color="#118ab2", bold=True)) - table.add_column("Value") - params = sorted( - ( - "debug", - "dev_mode", - "app_path", - "redis_host", - "redis_db", - "redis_dsn", - "host", - "port", - ) - ) - for attr in params: - table.add_row(f"hyperglass_{attr}".upper(), Pretty(getattr(self, attr))) - - yield Panel.fit(table, title="hyperglass settings", border_style="subtle") - - @field_validator("host", mode="before") - def validate_host( - cls: "HyperglassSettings", value: t.Any, info: ValidationInfo - ) -> IPvAnyAddress: - """Set default host based on debug mode.""" - - if value is None: - if info.data.get("debug") is False: - return ip_address("::1") - if info.data.get("debug") is True: - return ip_address("::") - - if isinstance(value, str): - if value != "localhost": - try: - return ip_address(value) - except ValueError as err: - raise ValueError(str(value)) from err - - elif value == "localhost": - return ip_address("::1") - - raise ValueError(str(value)) - - @field_validator("redis_dsn", mode="before") - def validate_redis_dsn(cls, value: t.Any, info: ValidationInfo) -> RedisDsn: - """Construct a Redis DSN if none is provided.""" - if value is None: - host = info.data.get("redis_host") - db = info.data.get("redis_db") - dsn = "redis://{}/{!s}".format(host, db) - password = info.data.get("redis_password") - if password is not None: - dsn = "redis://:{}@{}/{!s}".format(password.get_secret_value(), host, db) - return dsn - return value - - def bind(self: "HyperglassSettings") -> str: - """Format a listen_address. Wraps IPv6 address in brackets.""" - if self.host.version == 6: - return f"[{self.host!s}]:{self.port!s}" - return f"{self.host!s}:{self.port!s}" - - @property - def log_level(self: "HyperglassSettings") -> str: - """Get log level as string, inferred from debug mode.""" - if self.debug: - return "DEBUG" - return "WARNING" - - @property - def workers(self: "HyperglassSettings") -> int: - """Get worker count, inferred from debug mode.""" - if self.debug: - return 1 - return cpu_count(2) - - @property - def redis(self: "HyperglassSettings") -> t.Dict[str, t.Union[None, int, str]]: - """Get redis parameters as a dict for convenient connection setups.""" - password = None - if self.redis_password is not None: - password = self.redis_password.get_secret_value() - - return { - "db": self.redis_db, - "host": self.redis_host, - "password": password, - } - - @property - def redis_connection_pool(self: "HyperglassSettings") -> t.Dict[str, t.Any]: - """Get Redis ConnectionPool keyword arguments.""" - return {"url": str(self.redis_dsn), "max_connections": at_least(8, cpu_count(2))} - - @property - def dev_url(self: "HyperglassSettings") -> str: - """Get the hyperglass URL for when dev_mode is enabled.""" - return f"http://localhost:{self.port!s}/" - - @property - def prod_url(self: "HyperglassSettings") -> str: - """Get the UI-facing hyperglass URL/path.""" - return "/api/" - - @property - def static_path(self: "HyperglassSettings") -> Path: - """Get static asset path.""" - return Path(self.app_path / "static") diff --git a/src/stale/hyperglass/hyperglass/models/tests/__init__.py b/src/stale/hyperglass/hyperglass/models/tests/__init__.py deleted file mode 100644 index bb9fb66..0000000 --- a/src/stale/hyperglass/hyperglass/models/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Model tests.""" diff --git a/src/stale/hyperglass/hyperglass/models/tests/test_multi_model.py b/src/stale/hyperglass/hyperglass/models/tests/test_multi_model.py deleted file mode 100644 index 43d8083..0000000 --- a/src/stale/hyperglass/hyperglass/models/tests/test_multi_model.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Test HyperglassMultiModel.""" - -# Third Party -from pydantic import BaseModel - -# Local -from ..main import MultiModel - - -class Item(BaseModel): - """Test item.""" - - id: str - name: str - - -class Items(MultiModel, model=Item, unique_by="id"): - """Multi Model Test.""" - - -ITEMS_1 = [ - {"id": "item1", "name": "Item One"}, - Item(id="item2", name="Item Two"), - {"id": "item3", "name": "Item Three"}, -] - -ITEMS_2 = [ - Item(id="item4", name="Item Four"), - {"id": "item5", "name": "Item Five"}, -] - -ITEMS_3 = [ - {"id": "item1", "name": "Item New One"}, - {"id": "item6", "name": "Item Six"}, -] - - -def test_multi_model(): - model = Items(*ITEMS_1) - assert model.count == 3 - assert len([o for o in model]) == model.count # noqa: C416 (Iteration testing) - assert model["item1"].name == "Item One" - model.add(*ITEMS_2) - assert model.count == 5 - assert model[3].name == "Item Four" - model.add(*ITEMS_3, unique_by="id") - assert model.count == 6 - assert model["item1"].name == "Item New One" diff --git a/src/stale/hyperglass/hyperglass/models/tests/test_util.py b/src/stale/hyperglass/hyperglass/models/tests/test_util.py deleted file mode 100644 index f990d57..0000000 --- a/src/stale/hyperglass/hyperglass/models/tests/test_util.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Test model utilities.""" - -# Third Party -import pytest - -# Local -from ..util import check_legacy_fields - - -@pytest.mark.dependency() -def test_check_legacy_fields(): - test1 = {"name": "Device A", "nos": "juniper"} - test1_expected = {"name": "Device A", "platform": "juniper"} - test2 = {"name": "Device B", "platform": "juniper"} - test3 = {"name": "Device C"} - test4 = {"name": "Device D", "network": "this is wrong"} - - assert set(check_legacy_fields(model="Device", data=test1).keys()) == set( - 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" - ) - - with pytest.raises(ValueError): - check_legacy_fields(model="Device", data=test3) - - with pytest.raises(ValueError): - check_legacy_fields(model="Device", data=test4) diff --git a/src/stale/hyperglass/hyperglass/models/ui.py b/src/stale/hyperglass/hyperglass/models/ui.py deleted file mode 100644 index f8a304c..0000000 --- a/src/stale/hyperglass/hyperglass/models/ui.py +++ /dev/null @@ -1,64 +0,0 @@ -"""UI Configuration models.""" - -# Standard Library -import typing as t - -# Local -from .main import HyperglassModel -from .config.web import WebPublic -from .config.cache import Cache -from .config.params import ParamsPublic -from .config.messages import Messages - -Alignment = t.Union[t.Literal["left"], t.Literal["center"], t.Literal["right"], None] -StructuredDataField = t.Tuple[str, str, Alignment] - - -class UIDirective(HyperglassModel): - """UI: Directive.""" - - id: str - name: str - field_type: t.Union[str, None] - groups: t.List[str] - description: str - info: t.Optional[str] = None - options: t.Optional[t.List[t.Dict[str, t.Any]]] = None - - -class UILocation(HyperglassModel): - """UI: Location (Device).""" - - id: str - name: str - group: t.Optional[str] = None - avatar: t.Optional[str] = None - description: t.Optional[str] = None - directives: t.List[UIDirective] = [] - - -class UIDevices(HyperglassModel): - """UI: Devices.""" - - group: t.Optional[str] = None - locations: t.List[UILocation] = [] - - -class UIContent(HyperglassModel): - """UI: Content.""" - - credit: str - greeting: str - - -class UIParameters(ParamsPublic, HyperglassModel): - """UI Configuration Parameters.""" - - cache: Cache - web: WebPublic - messages: Messages - version: str - devices: t.List[UIDevices] = [] - parsed_data_fields: t.Tuple[StructuredDataField, ...] - content: UIContent - developer_mode: bool diff --git a/src/stale/hyperglass/hyperglass/models/util.py b/src/stale/hyperglass/hyperglass/models/util.py deleted file mode 100644 index 75f432c..0000000 --- a/src/stale/hyperglass/hyperglass/models/util.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Model utilities.""" - -# Standard Library -import typing as t - -# Third Party -from pydantic import BaseModel - -# Project -from hyperglass.log import log - - -class LegacyField(BaseModel): - """Define legacy fields on a per-model basis. - - When `overwrite` is `True`, the old key is replaced with the new - key. This will generally only occur when the value type is the same, - and the key name has only changed names for clarity or cosmetic - purposes. - - When `overwrite` is `False` and the old key is found, an error is - raised. This generally occurs when the overall function of the old - and new keys has remained the same, but the value type has changed, - requiring the user to make changes to the config file. - - When `required` is `True` and neither the old or new keys are found, - an error is raised. When `required` is false and neither keys are - found, nothing happens. - """ - - old: str - new: str - overwrite: bool = False - required: bool = True - - -LEGACY_FIELDS: t.Dict[str, t.Tuple[LegacyField, ...]] = { - "Device": ( - LegacyField(old="nos", new="platform", overwrite=True), - LegacyField(old="network", new="group", overwrite=False, required=False), - ), - "Proxy": (LegacyField(old="nos", new="platform", overwrite=True),), -} - - -def check_legacy_fields(*, model: str, data: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: - """Check for legacy fields prior to model initialization.""" - if model in LEGACY_FIELDS: - for field in LEGACY_FIELDS[model]: - legacy_value = data.pop(field.old, None) - new_value = data.get(field.new) - if legacy_value is not None and new_value is None: - if field.overwrite: - log.bind(old_field=f"{model}.{field.old}", new_field=field.new).warning( - "Deprecated field" - ) - data[field.new] = legacy_value - else: - raise ValueError( - ( - "The {!r} field has been replaced with the {!r} field. " - "Please consult the documentation and/or changelog to determine the appropriate migration path." - ).format(f"{model}.{field.old}", field.new) - ) - elif legacy_value is None and new_value is None and field.required: - raise ValueError(f"'{field.new}' is missing") - return data diff --git a/src/stale/hyperglass/hyperglass/models/webhook.py b/src/stale/hyperglass/hyperglass/models/webhook.py deleted file mode 100644 index 2c83193..0000000 --- a/src/stale/hyperglass/hyperglass/models/webhook.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Data models used throughout hyperglass.""" - -# Standard Library -import typing as t -from datetime import datetime - -# Third Party -from pydantic import ConfigDict, model_validator - -# Project -from hyperglass.log import log - -# Local -from .main import HyperglassModel - -_WEBHOOK_TITLE = "hyperglass received a valid query with the following data" -_ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon.png" - - -def to_snake_case(value: str) -> str: - """Convert string to snake case.""" - return value.replace("_", "-") - - -class WebhookHeaders(HyperglassModel): - """Webhook data model.""" - - model_config = ConfigDict(alias_generator=to_snake_case) - - user_agent: t.Optional[str] = None - referer: t.Optional[str] = None - accept_encoding: t.Optional[str] = None - accept_language: t.Optional[str] = None - x_real_ip: t.Optional[str] = None - x_forwarded_for: t.Optional[str] = None - - -class WebhookNetwork(HyperglassModel): - """Webhook data model.""" - - model_config = ConfigDict(extra="allow") - - prefix: str = "Unknown" - asn: str = "Unknown" - org: str = "Unknown" - country: str = "Unknown" - - -class Webhook(HyperglassModel): - """Webhook data model.""" - - query_location: str - query_type: str - query_target: t.Union[t.List[str], str] - headers: WebhookHeaders - source: str = "Unknown" - network: WebhookNetwork - timestamp: datetime - - @model_validator(mode="before") - def validate_webhook(cls, model: "Webhook") -> "Webhook": - """Reset network attributes if the source is localhost.""" - if model.source in ("127.0.0.1", "::1"): - model.network = {} - return model - - def msteams(self) -> t.Dict[str, t.Any]: - """Format the webhook data as a Microsoft Teams card.""" - - def code(value: t.Any) -> str: - """Wrap argument in backticks for markdown inline code formatting.""" - return f"`{str(value)}`" - - header_data = [ - {"name": k, "value": code(v)} for k, v in self.headers.model_dump(by_alias=True).items() - ] - time_fmt = self.timestamp.strftime("%Y %m %d %H:%M:%S") - payload = { - "@type": "MessageCard", - "@context": "http://schema.org/extensions", - "themeColor": "118ab2", - "summary": _WEBHOOK_TITLE, - "sections": [ - { - "activityTitle": _WEBHOOK_TITLE, - "activitySubtitle": f"{time_fmt} UTC", - "activityImage": _ICON_URL, - "facts": [ - {"name": "Query Location", "value": self.query_location}, - {"name": "Query Target", "value": code(self.query_target)}, - {"name": "Query Type", "value": self.query_type}, - ], - }, - {"markdown": True, "text": "**Source Information**"}, - {"markdown": True, "text": "---"}, - { - "markdown": True, - "facts": [ - {"name": "IP", "value": code(self.source)}, - {"name": "Prefix", "value": code(self.network.prefix)}, - {"name": "ASN", "value": code(self.network.asn)}, - {"name": "Country", "value": self.network.country}, - {"name": "Organization", "value": self.network.org}, - ], - }, - {"markdown": True, "text": "**Request Headers**"}, - {"markdown": True, "text": "---"}, - {"markdown": True, "facts": header_data}, - ], - } - log.bind(type="MS Teams", payload=str(payload)).debug("Created webhook") - - return payload - - def slack(self) -> t.Dict[str, t.Any]: - """Format the webhook data as a Slack message.""" - - def make_field(key, value, code=False): - if code: - value = f"`{value}`" - return f"*{key}*\n{value}" - - header_data = [] - for k, v in self.headers.model_dump(by_alias=True).items(): - field = make_field(k, v, code=True) - header_data.append(field) - - query_data = [ - {"type": "mrkdwn", "text": make_field("Query Location", self.query_location)}, - {"type": "mrkdwn", "text": make_field("Query Target", self.query_target, code=True)}, - {"type": "mrkdwn", "text": make_field("Query Type", self.query_type)}, - ] - - source_data = [ - {"type": "mrkdwn", "text": make_field("Source IP", self.source, code=True)}, - { - "type": "mrkdwn", - "text": make_field("Source Prefix", self.network.prefix, code=True), - }, - {"type": "mrkdwn", "text": make_field("Source ASN", self.network.asn, code=True)}, - {"type": "mrkdwn", "text": make_field("Source Country", self.network.country)}, - {"type": "mrkdwn", "text": make_field("Source Organization", self.network.org)}, - ] - - time_fmt = self.timestamp.strftime("%Y %m %d %H:%M:%S") - - payload = { - "text": _WEBHOOK_TITLE, - "blocks": [ - {"type": "section", "text": {"type": "mrkdwn", "text": f"*{time_fmt} UTC*"}}, - {"type": "section", "fields": query_data}, - {"type": "divider"}, - {"type": "section", "fields": source_data}, - {"type": "divider"}, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "*Headers*\n" + "\n".join(header_data)}, - }, - ], - } - log.bind(type="Slack", payload=str(payload)).debug("Created webhook") - return payload diff --git a/src/stale/hyperglass/hyperglass/plugins/__init__.py b/src/stale/hyperglass/hyperglass/plugins/__init__.py deleted file mode 100644 index 52933a1..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""hyperglass Plugins.""" - -# Local -from .main import register_plugin, init_builtin_plugins -from ._input import InputPlugin, InputPluginValidationReturn -from ._output import OutputType, OutputPlugin -from ._manager import InputPluginManager, OutputPluginManager - -__all__ = ( - "init_builtin_plugins", - "InputPlugin", - "InputPluginManager", - "InputPluginValidationReturn", - "OutputPlugin", - "OutputPluginManager", - "OutputType", - "register_plugin", -) diff --git a/src/stale/hyperglass/hyperglass/plugins/_base.py b/src/stale/hyperglass/hyperglass/plugins/_base.py deleted file mode 100644 index ea4e2e1..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_base.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Base Plugin Definition.""" - -# Standard Library -import typing as t -from abc import ABC -from inspect import Signature - -# Third Party -from pydantic import BaseModel, PrivateAttr - -# Project -from hyperglass.log import log as _logger - -if t.TYPE_CHECKING: - # Third Party - from loguru import Logger - -PluginType = t.Union[t.Literal["output"], t.Literal["input"]] -SupportedMethod = t.TypeVar("SupportedMethod") - - -class HyperglassPlugin(BaseModel, ABC): - """Plugin to interact with device command output.""" - - _hyperglass_builtin: bool = PrivateAttr(False) - _type: t.ClassVar[str] - name: str - common: bool = False - ref: t.Optional[str] = None - log: t.ClassVar["Logger"] = _logger - - @property - def _signature(self) -> Signature: - """Get this instance's class signature.""" - return self.__class__.__signature__ - - def __eq__(self, other: "HyperglassPlugin") -> bool: - """Other plugin is equal to this plugin.""" - if hasattr(other, "_signature"): - return other and self._signature == other._signature - return False - - def __ne__(self, other: "HyperglassPlugin") -> bool: - """Other plugin is not equal to this plugin.""" - return not self.__eq__(other) - - def __hash__(self) -> int: - """Create a hashed representation of this plugin's name.""" - return hash(self._signature) - - def __str__(self) -> str: - """Represent plugin by its name.""" - return self.name - - @classmethod - def __init_subclass__(cls, **kwargs: t.Any) -> None: - """Initialize plugin object.""" - name = kwargs.pop("name", None) or cls.__name__ - cls.name = name - super().__init_subclass__() - - def __init__(self, **kwargs: t.Any) -> None: - """Initialize plugin instance.""" - name = kwargs.pop("name", None) or self.__class__.__name__ - super().__init__(name=name, **kwargs) - - def __rich_console__(self, *_, **__): - """Create a rich representation of this plugin for the hyperglass CLI.""" - - # Third Party - from rich.text import Text - from rich.panel import Panel - from rich.table import Table - from rich.pretty import Pretty - - table = Table.grid(padding=(0, 1), expand=False) - table.add_column(justify="right") - - data = {"builtin": True if self._hyperglass_builtin else False} - data.update( - { - attr: getattr(self, attr) - for attr in ("name", "common", "directives", "platforms") - if hasattr(self, attr) - } - ) - data = {k: data[k] for k in sorted(data.keys())} - for key, value in data.items(): - table.add_row( - Text.assemble((key, "inspect.attr"), (" =", "inspect.equals")), Pretty(value) - ) - - yield Panel( - table, - expand=False, - title=f"[bold magenta]{self.name}", - title_align="left", - subtitle=f"[bold cornflower_blue]{self._type.capitalize()} Plugin", - subtitle_align="right", - padding=(1, 3), - ) - - -class DirectivePlugin(BaseModel): - """Plugin associated with directives. - - Should always be subclassed with `HyperglassPlugin`. - """ - - directives: t.Sequence[str] = () - - -class PlatformPlugin(BaseModel): - """Plugin associated with specific device platform. - - Should always be subclassed with `HyperglassPlugin`. - """ - - platforms: t.Sequence[str] = () diff --git a/src/stale/hyperglass/hyperglass/plugins/_builtin/__init__.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/__init__.py deleted file mode 100644 index 5e87f5b..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""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/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_community.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_community.py deleted file mode 100644 index 5573d9e..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_community.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Remove anything before the command if found in output.""" - -# Standard Library -import typing as t -from ipaddress import ip_address - -# Third Party -from pydantic import PrivateAttr - -# Project -from hyperglass.state.hooks import use_state - -# Local -from .._input import InputPlugin - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.api.query import Query - - # Local - from .._input import InputPluginValidationReturn - -_32BIT = 0xFFFFFFFF -_16BIT = 0xFFFF -EXTENDED_TYPES = ("target", "origin") - - -def check_decimal(value: str, size: int) -> bool: - """Verify the value is a 32 bit number.""" - try: - return abs(int(value)) <= size - except Exception: - return False - - -def check_string(value: str) -> bool: - """Verify part of a community is an IPv4 address, per RFC4360.""" - try: - addr = ip_address(value) - return addr.version == 4 - except ValueError: - return False - - -def validate_decimal(value: str) -> bool: - """Verify a community is a 32 bit decimal number.""" - return check_decimal(value, _32BIT) - - -def validate_new_format(value: str) -> bool: - """Verify a community matches "new" format, standard or extended.""" - if ":" in value: - parts = [p for p in value.split(":") if p] - if len(parts) == 3: - if parts[0].lower() not in EXTENDED_TYPES: - # Handle extended community format with `target:` or `origin:` prefix. - return False - # Remove type from parts list after it's been validated. - parts = parts[1:] - if len(parts) != 2: - # Only allow two sections in new format, e.g. 65000:1 - return False - - one, two = parts - - if all((check_decimal(one, _16BIT), check_decimal(two, _16BIT))): - # Handle standard format, e.g. `65000:1` - return True - if all((check_decimal(one, _16BIT), check_decimal(two, _32BIT))): - # Handle extended format, e.g. `65000:4294967295` - return True - if all((check_string(one), check_decimal(two, _16BIT))): - # Handle IP address format, e.g. `192.0.2.1:65000` - return True - - return False - - -def validate_large_community(value: str) -> bool: - """Verify a community matches "large" format. E.g., `65000:65001:65002`.""" - if ":" in value: - parts = [p for p in value.split(":") if p] - if len(parts) != 3: - return False - for part in parts: - if not check_decimal(part, _32BIT): - # Each member must be a 32 bit number. - return False - return True - return False - - -class ValidateBGPCommunity(InputPlugin): - """Validate a BGP community string.""" - - _hyperglass_builtin: bool = PrivateAttr(True) - - def validate(self, query: "Query") -> "InputPluginValidationReturn": - """Ensure an input query target is a valid BGP community.""" - - params = use_state("params") - - if not isinstance(query.query_target, str): - return None - - for validator in (validate_decimal, validate_new_format, validate_large_community): - result = validator(query.query_target) - if result is True: - return True - - self.failure_reason = params.messages.invalid_input - return False diff --git a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_arista.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_arista.py deleted file mode 100644 index b65310a..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_arista.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Parse Arista 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.arista_eos import AristaBGPTable - -# 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_arista(output: t.Sequence[str]) -> "OutputDataModel": - """Parse a Arista BGP JSON response.""" - result = None - - _log = log.bind(plugin=BGPRoutePluginArista.__name__) - - for response in output: - try: - parsed: t.Dict = json.loads(response) - - _log.debug("Pre-parsed data", data=parsed) - - vrf = list(parsed["vrfs"].keys())[0] - routes = parsed["vrfs"][vrf] - - validated = AristaBGPTable(**routes) - 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 BGPRoutePluginArista(OutputPlugin): - """Coerce a Arista route table in JSON format to a standard BGP Table structure.""" - - _hyperglass_builtin: bool = PrivateAttr(True) - platforms: t.Sequence[str] = ("arista_eos",) - directives: t.Sequence[str] = ( - "__hyperglass_arista_eos_bgp_route_table__", - "__hyperglass_arista_eos_bgp_aspath_table__", - "__hyperglass_arista_eos_bgp_community_table__", - ) - - def process(self, *, output: "OutputType", query: "Query") -> "OutputType": - """Parse Arista 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_arista(output) - return output diff --git a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_frr.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_frr.py deleted file mode 100644 index 7130b20..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_frr.py +++ /dev/null @@ -1,85 +0,0 @@ -"""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/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_huawei.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_huawei.py deleted file mode 100644 index 9419451..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ /dev/null @@ -1,47 +0,0 @@ -# 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/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_juniper.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_juniper.py deleted file mode 100644 index eb5d38e..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/bgp_route_juniper.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Coerce a Juniper route table in XML format to a standard BGP Table structure.""" - -# Standard Library -import re -from typing import TYPE_CHECKING, List, Sequence, Generator - -# Third Party -import xmltodict # type: ignore -from pydantic import PrivateAttr, ValidationError - -# Project -from hyperglass.log import log -from hyperglass.exceptions.private import ParsingError -from hyperglass.models.parsing.juniper import JuniperBGPTable - -# Local -from .._output import OutputPlugin - -if TYPE_CHECKING: - # Standard Library - from collections import OrderedDict - - # Project - from hyperglass.models.data import OutputDataModel - from hyperglass.models.api.query import Query - - # Local - from .._output import OutputType - - -REMOVE_PATTERNS = ( - # The XML response can a CLI banner appended to the end of the XML - # string. For example: - # ``` - # <rpc-reply> - # ... - # <cli> - # <banner>{master}</banner> - # </cli> - # </rpc-reply> - # - # {master} noqa: E800 - # ``` - # - # This pattern will remove anything inside braces, including the braces. - r"\{.+\}", -) - - -def clean_xml_output(output: str) -> str: - """Remove Juniper-specific patterns from output.""" - - def scrub(lines: List[str]) -> Generator[str, None, None]: - """Clean & remove each pattern from each line.""" - for pattern in REMOVE_PATTERNS: - for line in lines: - # Remove the pattern & strip extra newlines - scrubbed = re.sub(pattern, "", line.strip()) - # Only return non-empty and non-newline lines - if scrubbed and scrubbed != "\n": - yield scrubbed - - lines = scrub(output.splitlines()) - - return "\n".join(lines) - - -def parse_juniper(output: Sequence[str]) -> "OutputDataModel": # noqa: C901 - """Parse a Juniper BGP XML response.""" - result = None - - _log = log.bind(plugin=BGPRoutePluginJuniper.__name__) - for response in output: - cleaned = clean_xml_output(response) - - try: - parsed: "OrderedDict" = xmltodict.parse( - cleaned, force_list=("rt", "rt-entry", "community") - ) - if "rpc-reply" in parsed.keys(): - if "xnm:error" in parsed["rpc-reply"]: - if "message" in parsed["rpc-reply"]["xnm:error"]: - err = parsed["rpc-reply"]["xnm:error"]["message"] - raise ParsingError('Error from device: "{}"', err) - - parsed_base = parsed["rpc-reply"]["route-information"] - elif "route-information" in parsed.keys(): - parsed_base = parsed["route-information"] - - if "route-table" not in parsed_base: - return result - - if "rt" not in parsed_base["route-table"]: - return result - - parsed = parsed_base["route-table"] - validated = JuniperBGPTable(**parsed) - bgp_table = validated.bgp_table() - - if result is None: - result = bgp_table - else: - result += bgp_table - - except xmltodict.expat.ExpatError as err: - _log.bind(error=str(err)).critical("Failed to decode XML") - 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("{key} was not found in the response", key=str(err)) from err - - except ValidationError as err: - _log.critical(err) - raise ParsingError(err) from err - - return result - - -class BGPRoutePluginJuniper(OutputPlugin): - """Coerce a Juniper route table in XML format to a standard BGP Table structure.""" - - _hyperglass_builtin: bool = PrivateAttr(True) - platforms: Sequence[str] = ("juniper",) - directives: Sequence[str] = ( - "__hyperglass_juniper_bgp_route_table__", - "__hyperglass_juniper_bgp_aspath_table__", - "__hyperglass_juniper_bgp_community_table__", - ) - - def process(self, *, output: "OutputType", query: "Query") -> "OutputType": - """Parse Juniper 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_juniper(output) - return output diff --git a/src/stale/hyperglass/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/mikrotik_garbage_output.py deleted file mode 100644 index 8718604..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/mikrotik_garbage_output.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Remove anything before the command if found in output.""" - -# Standard Library -import re -import typing as t - -# Third Party -from pydantic import PrivateAttr - -# Project -from hyperglass.types import Series - -# Local -from .._output import OutputType, OutputPlugin - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.api.query import Query - - -class MikrotikGarbageOutput(OutputPlugin): - """Parse Mikrotik output to remove garbage.""" - - _hyperglass_builtin: bool = PrivateAttr(True) - platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos") - directives: t.Sequence[str] = ( - "__hyperglass_mikrotik_bgp_aspath__", - "__hyperglass_mikrotik_bgp_community__", - "__hyperglass_mikrotik_bgp_route__", - "__hyperglass_mikrotik_ping__", - "__hyperglass_mikrotik_traceroute__", - ) - - def process(self, *, output: OutputType, query: "Query") -> Series[str]: - """Parse Mikrotik output to remove garbage.""" - - result = () - - for each_output in output: - if len(each_output) != 0: - if each_output.split()[-1] in ("DISTANCE", "STATUS"): - # Mikrotik shows the columns with no rows if there is no data. - # Rather than send back an empty table, send back an empty - # response which is handled with a warning message. - each_output = "" - else: - remove_lines = () - all_lines = each_output.splitlines() - # Starting index for rows (after the column row). - start = 1 - # Extract the column row. - column_line = " ".join(all_lines[0].split()) - - for i, line in enumerate(all_lines[1:]): - # Remove all the newline characters (which differ line to - # line) for comparison purposes. - normalized = " ".join(line.split()) - - # Remove ansii characters that aren't caught by Netmiko. - normalized = re.sub(r"\\x1b\[\S{2}\s", "", normalized) - - if column_line in normalized: - # Mikrotik often re-inserts the column row in the output, - # effectively 'starting over'. In that case, re-assign - # the column row and starting index to that point. - column_line = re.sub(r"\[\S{2}\s", "", line) - start = i + 2 - - if "[Q quit|D dump|C-z pause]" in normalized: - # Remove Mikrotik's unhelpful helpers from the output. - remove_lines += (i + 1,) - - # Combine the column row and the data rows from the starting - # index onward. - lines = [column_line, *all_lines[start:]] - - # Remove any lines marked for removal and re-join with a single - # newline character. - lines = [line for idx, line in enumerate(lines) if idx not in remove_lines] - result += ("\n".join(lines),) - - return result diff --git a/src/stale/hyperglass/hyperglass/plugins/_builtin/remove_command.py b/src/stale/hyperglass/hyperglass/plugins/_builtin/remove_command.py deleted file mode 100644 index bc301ca..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_builtin/remove_command.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Remove anything before the command if found in output.""" - -# Standard Library -from typing import TYPE_CHECKING, Sequence - -# Third Party -from pydantic import PrivateAttr - -# Project -from hyperglass.util.typing import is_series - -# Local -from .._output import OutputType, OutputPlugin - -if TYPE_CHECKING: - # Project - from hyperglass.models.api.query import Query - - -class RemoveCommand(OutputPlugin): - """Remove anything before the command if found in output.""" - - _hyperglass_builtin: bool = PrivateAttr(True) - - def process(self, *, output: OutputType, query: "Query") -> Sequence[str]: - """Remove anything before the command if found in output.""" - - def _remove_command(output_in: str) -> str: - output_out = output_in.strip().split("\n") - - for command in query.device.directive_commands: - for line in output_out: - if command in line: - idx = output_out.index(line) + 1 - output_out = output_out[idx:] - - return "\n".join(output_out) - - if is_series(output): - return tuple(_remove_command(o) for o in output) - - return output diff --git a/src/stale/hyperglass/hyperglass/plugins/_input.py b/src/stale/hyperglass/hyperglass/plugins/_input.py deleted file mode 100644 index e2316bf..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_input.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Input validation plugins.""" - -# Standard Library -import typing as t - -# Local -from ._base import DirectivePlugin, HyperglassPlugin - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.api.query import Query - - -InputPluginValidationReturn = t.Union[None, bool] -InputPluginTransformReturn = t.Union[t.Sequence[str], str] - - -class InputPlugin(HyperglassPlugin, DirectivePlugin): - """Plugin to validate user input prior to running commands.""" - - _type = "input" - failure_reason: t.Optional[str] = None - - def validate(self, query: "Query") -> InputPluginValidationReturn: - """Validate input from hyperglass UI/API.""" - return None - - def transform(self, query: "Query") -> InputPluginTransformReturn: - """Transform query target prior to running commands.""" - return query.query_target diff --git a/src/stale/hyperglass/hyperglass/plugins/_manager.py b/src/stale/hyperglass/hyperglass/plugins/_manager.py deleted file mode 100644 index c0194ac..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_manager.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Plugin manager definition.""" - -# Standard Library -import typing as t -from inspect import isclass - -# Project -from hyperglass.log import log -from hyperglass.state import use_state -from hyperglass.exceptions.private import PluginError, InputValidationError - -# Local -from ._base import PluginType, HyperglassPlugin -from ._input import InputPlugin, InputPluginTransformReturn, InputPluginValidationReturn -from ._output import OutputType, OutputPlugin - -if t.TYPE_CHECKING: - # Project - from hyperglass.state import HyperglassState - from hyperglass.models.api.query import Query - -PluginT = t.TypeVar("PluginT", bound=HyperglassPlugin) - - -class PluginManager(t.Generic[PluginT]): - """Manage all plugins.""" - - _type: PluginType - _state: "HyperglassState" - _index: int = 0 - _cache_key: str - - def __init__(self: "PluginManager") -> None: - """Initialize plugin manager.""" - self._state = use_state() - self._cache_key = f"hyperglass.plugins.{self._type}" - - def __init_subclass__(cls: "PluginManager", **kwargs: PluginType) -> None: - """Set this plugin manager's type on subclass initialization.""" - _type = kwargs.get("type", None) or cls._type - if _type is None: - raise PluginError("Plugin '{}' is missing a 'type', keyword argument", repr(cls)) - cls._type = _type - return super().__init_subclass__() - - def __iter__(self: "PluginManager") -> "PluginManager": - """Plugin manager iterator.""" - return self - - def __next__(self: "PluginManager") -> PluginT: - """Plugin manager iteration.""" - if self._index <= len(self.plugins()): - result = self.plugins()[self._index - 1] - self._index += 1 - return result - self._index = 0 - raise StopIteration - - def plugins(self: "PluginManager", *, builtins: bool = True) -> t.List[PluginT]: - """Get all plugins, with built-in plugins last.""" - plugins = self._state.plugins(self._type) - - if builtins is False: - plugins = [p for p in plugins if p._hyperglass_builtin is False] - - # Sort plugins by their name attribute, which is the name of the class by default. - sorted_by_name = sorted(plugins, key=lambda p: str(p)) - - # Sort with built-in plugins last. - return sorted( - sorted_by_name, - key=lambda p: -1 if p._hyperglass_builtin else 1, - reverse=True, - ) - - @property - def name(self: PluginT) -> str: - """Get this plugin manager's name.""" - return self.__class__.__name__ - - def methods(self: "PluginManager", name: str) -> t.Generator[t.Callable, None, None]: - """Get methods of all registered plugins matching `name`.""" - for plugin in self.plugins(): - if hasattr(plugin, name): - method = getattr(plugin, name) - if callable(method): - yield method - - def execute(self, *args, **kwargs) -> None: - """Gather all plugins and execute in order.""" - raise NotImplementedError(f"Plugin Manager '{self.name}' is missing an 'execute()' method.") - - def reset(self: "PluginManager") -> None: - """Remove all plugins.""" - self._index = 0 - self._state.reset_plugins(self._type) - - def unregister(self: "PluginManager", plugin: PluginT) -> None: - """Remove a plugin from currently active plugins.""" - if isclass(plugin): - if issubclass(plugin, HyperglassPlugin): - self._state.remove_plugin(self._type, plugin) - - return - raise PluginError("Plugin '{}' is not a valid hyperglass plugin", repr(plugin)) - - def register(self: "PluginManager", plugin: PluginT, *args: t.Any, **kwargs: t.Any) -> None: - """Add a plugin to currently active plugins.""" - # Create a set of plugins so duplicate plugins are not mistakenly added. - try: - if issubclass(plugin, HyperglassPlugin): - instance = plugin(*args, **kwargs) - self._state.add_plugin(self._type, instance) - _log = log.bind(type=self._type, name=instance.name) - if instance._hyperglass_builtin is True: - _log.debug("Registered built-in plugin") - else: - _log.info("Registered plugin") - return - except TypeError: - raise PluginError( # noqa: B904 - "Plugin '{p}' has not defined a required method. " - "Please consult the hyperglass documentation.", - p=repr(plugin), - ) - raise PluginError("Plugin '{p}' is not a valid hyperglass plugin", p=repr(plugin)) - - -class InputPluginManager(PluginManager[InputPlugin], type="input"): - """Manage Input Validation Plugins.""" - - def _gather_plugins( - self: "InputPluginManager", query: "Query" - ) -> t.Generator[InputPlugin, None, None]: - for plugin in self.plugins(builtins=True): - if plugin.directives and query.directive.id in plugin.directives: - yield plugin - if plugin.ref in query.directive.plugins: - yield plugin - if plugin.common is True: - yield plugin - - def validate(self: "InputPluginManager", query: "Query") -> InputPluginValidationReturn: - """Execute all input validation plugins. - - If any plugin returns `False`, execution is halted. - """ - result = None - for plugin in self._gather_plugins(query): - result = plugin.validate(query) - result_test = "valid" if result is True else "invalid" if result is False else "none" - log.bind(name=plugin.name, result=result_test).debug("Input Plugin Validation") - if result is False: - raise InputValidationError( - error="No matched validation rules", target=query.query_target - ) - if result is True: - return result - return result - - def transform(self: "InputPluginManager", *, query: "Query") -> InputPluginTransformReturn: - """Execute all input transformation plugins.""" - result = query.query_target - for plugin in self._gather_plugins(query): - result = plugin.transform(query=query.summary()) - log.bind(name=plugin.name, result=repr(result)).debug("Input Plugin Transform") - return result - - -class OutputPluginManager(PluginManager[OutputPlugin], type="output"): - """Manage Output Processing Plugins.""" - - def execute(self: "OutputPluginManager", *, output: OutputType, query: "Query") -> OutputType: - """Execute all output parsing plugins. - - The result of each plugin is passed to the next plugin. - """ - result = output - directives = ( - plugin - for plugin in self.plugins() - if query.directive.id in plugin.directives and query.device.platform in plugin.platforms - ) - common = (plugin for plugin in self.plugins() if plugin.common is True) - for plugin in (*directives, *common): - log.bind(plugin=plugin.name, value=result).debug("Output Plugin Starting Value") - result = plugin.process(output=result, query=query) - log.bind(plugin=plugin.name, value=result).debug("Output Plugin Ending Value") - - if result is False: - return result - # Pass the result of each plugin to the next plugin. - return result diff --git a/src/stale/hyperglass/hyperglass/plugins/_output.py b/src/stale/hyperglass/hyperglass/plugins/_output.py deleted file mode 100644 index 8f14dd4..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/_output.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Device output plugins.""" - -# Standard Library -from typing import TYPE_CHECKING, Union - -# Project -from hyperglass.log import log -from hyperglass.types import Series - -# Local -from ._base import PlatformPlugin, DirectivePlugin, HyperglassPlugin - -if TYPE_CHECKING: - # Project - from hyperglass.models.data import OutputDataModel - from hyperglass.models.api.query import Query - -OutputType = Union["OutputDataModel", Series[str]] - - -class OutputPlugin(HyperglassPlugin, DirectivePlugin, PlatformPlugin): - """Plugin to interact with device command output.""" - - _type = "output" - - def process(self, *, output: OutputType, query: "Query") -> OutputType: - """Process or manipulate output from a device.""" - log.warning("Output plugin has not implemented a 'process()' method", plugin=self.name) - return output diff --git a/src/stale/hyperglass/hyperglass/plugins/external/__init__.py b/src/stale/hyperglass/hyperglass/plugins/external/__init__.py deleted file mode 100644 index 78c58eb..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/external/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Container for external plugins. External plugins are copied here on registration.""" diff --git a/src/stale/hyperglass/hyperglass/plugins/main.py b/src/stale/hyperglass/hyperglass/plugins/main.py deleted file mode 100644 index abc83fb..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/main.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Register all plugins.""" - -# Standard Library -import sys -import shutil -import typing as t -from inspect import isclass, getmembers -from pathlib import Path -from importlib.util import module_from_spec, spec_from_file_location - -# Local -from . import _builtin -from ._input import InputPlugin -from ._output import OutputPlugin -from ._manager import InputPluginManager, OutputPluginManager - - -def _is_class(module: t.Any, obj: object) -> bool: - if isclass(obj): - # Get the object's containing module name. - obj_module_name: str = getattr(obj, "__module__", "") - # Get the module's name. - module_name: str = getattr(module, "__name__", None) - # Only validate objects that are members of the module. - return module_name in obj_module_name - return False - - -def _register_from_module(module: t.Any, **kwargs: t.Any) -> t.Tuple[str, ...]: - """Register defined classes from the module.""" - failures = () - defs = getmembers(module, lambda o: _is_class(module, o)) - sys.modules[module.__name__] = module - for name, plugin in defs: - if issubclass(plugin, OutputPlugin): - manager = OutputPluginManager() - elif issubclass(plugin, InputPlugin): - manager = InputPluginManager() - else: - failures += (name,) - continue - manager.register(plugin, **kwargs) - return failures - - -def _module_from_file(file: Path) -> t.Any: - """Import a plugin module from its file Path object.""" - plugins_dir = Path(__file__).parent / "external" - dst = plugins_dir / f"imported_{file.name}" - shutil.copy2(file, dst) - name = f"imported_{file.name.split('.')[0]}" - spec = spec_from_file_location(f"hyperglass.plugins.external.{name}", dst) - module = module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def init_builtin_plugins() -> None: - """Initialize all built-in plugins.""" - _register_from_module(_builtin) - - -def register_plugin(plugin_file: Path, **kwargs) -> t.Tuple[str, ...]: - """Register an external plugin by file path.""" - if plugin_file.exists(): - module = _module_from_file(plugin_file) - results = _register_from_module(module, ref=plugin_file.stem, **kwargs) - return results - raise FileNotFoundError(str(plugin_file)) diff --git a/src/stale/hyperglass/hyperglass/plugins/tests/__init__.py b/src/stale/hyperglass/hyperglass/plugins/tests/__init__.py deleted file mode 100644 index 30d0597..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Plugin tests.""" diff --git a/src/stale/hyperglass/hyperglass/plugins/tests/_fixtures.py b/src/stale/hyperglass/hyperglass/plugins/tests/_fixtures.py deleted file mode 100644 index f054a3c..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/tests/_fixtures.py +++ /dev/null @@ -1,7 +0,0 @@ -# Project -from hyperglass.models.config.devices import Device - - -class MockDevice(Device): - def has_directives(self, *_: str) -> bool: - return True diff --git a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_community.py b/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_community.py deleted file mode 100644 index 3541244..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_community.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Test BGP Community validation.""" - -# Standard Library -import typing as t - -# Third Party -import pytest - -# Project -from hyperglass.state import use_state -from hyperglass.models.config.params import Params - -# Local -from .._builtin.bgp_community import ValidateBGPCommunity - -if t.TYPE_CHECKING: - # Project - from hyperglass.state import HyperglassState - - -CHECKS = ( - ("32768", True), - ("65000:1", True), - ("65000:4294967296", False), - ("4294967295:65000", False), - ("192.0.2.1:65000", True), - ("65000:192.0.2.1", False), - ("target:65000:1", True), - ("origin:65001:1", True), - ("wrong:65000:1", False), - ("65000:65001:65002", True), - ("4294967295:4294967294:4294967293", True), - ("65000:4294967295:1", True), - ("65000:192.0.2.1:1", False), - ("gibberish", False), - ("192.0.2.1", False), - (True, None), - (type("FakeClass", (), {}), None), -) - - -@pytest.fixture -def params(): - return {} - - -@pytest.fixture -def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: - """Test fixture to initialize Redis store.""" - _state = use_state() - _params = Params(**params) - - with _state.cache.pipeline() as pipeline: - pipeline.set("params", _params) - - yield _state - _state.clear() - - -def test_bgp_community(state): - plugin = ValidateBGPCommunity() - - for value, expected in CHECKS: - query = type("Query", (), {"query_target": value}) - result = plugin.validate(query) - assert result == expected, f"Invalid value {value!r}" diff --git a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_arista.py b/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_arista.py deleted file mode 100644 index dbb02bc..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_arista.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Arista 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_arista import BGPRoutePluginArista - -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" / "arista_route.json" - - -def _tester(sample: str): - plugin = BGPRoutePluginArista() - - device = MockDevice( - name="Test Device", - address="127.0.0.1", - group="Test Network", - credential={"username": "", "password": ""}, - platform="arista", - structured_output=True, - directives=["__hyperglass_arista_eos_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_arista_route_sample(): - with SAMPLE.open("r") as file: - sample = file.read() - return _tester(sample) diff --git a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_frr.py b/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_frr.py deleted file mode 100644 index bd042b2..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_frr.py +++ /dev/null @@ -1,55 +0,0 @@ -"""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/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_juniper.py b/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_juniper.py deleted file mode 100644 index 7a02274..0000000 --- a/src/stale/hyperglass/hyperglass/plugins/tests/test_bgp_route_juniper.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Juniper BGP Route Parsing Tests.""" - -# flake8: noqa -# Standard Library -from pathlib import Path - -# Third Party -import pytest - -# Project -from hyperglass.models.data.bgp_route import BGPRouteTable - -# Local -from ._fixtures import MockDevice -from .._builtin.bgp_route_juniper import BGPRoutePluginJuniper - -DEPENDS_KWARGS = { - "depends": [ - "hyperglass/models/tests/test_util.py::test_check_legacy_fields", - "hyperglass/external/tests/test_rpki.py::test_rpki", - ], - "scope": "session", -} - -DIRECT = Path(__file__).parent.parent.parent.parent / ".samples" / "juniper_route_direct.xml" -INDIRECT = Path(__file__).parent.parent.parent.parent / ".samples" / "juniper_route_indirect.xml" -AS_PATH = Path(__file__).parent.parent.parent.parent / ".samples" / "juniper_route_aspath.xml" - - -def _tester(sample: str): - plugin = BGPRoutePluginJuniper() - - device = MockDevice( - name="Test Device", - address="127.0.0.1", - group="Test Network", - credential={"username": "", "password": ""}, - platform="juniper", - structured_output=True, - directives=[], - 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_juniper_bgp_route_direct(): - with DIRECT.open("r") as file: - sample = file.read() - return _tester(sample) - - -@pytest.mark.dependency(**DEPENDS_KWARGS) -def test_juniper_bgp_route_indirect(): - with INDIRECT.open("r") as file: - sample = file.read() - return _tester(sample) - - -@pytest.mark.dependency(**DEPENDS_KWARGS) -def test_juniper_bgp_route_aspath(): - with AS_PATH.open("r") as file: - sample = file.read() - return _tester(sample) diff --git a/src/stale/hyperglass/hyperglass/settings.py b/src/stale/hyperglass/hyperglass/settings.py deleted file mode 100644 index b8dac19..0000000 --- a/src/stale/hyperglass/hyperglass/settings.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Access hyperglass global system settings.""" - -# Standard Library -import typing as t - -if t.TYPE_CHECKING: - # Local - from .models.system import HyperglassSettings - - -def _system_settings() -> "HyperglassSettings": - """Get system settings from local environment.""" - # Local - from .models.system import HyperglassSettings - - return HyperglassSettings() - - -Settings = _system_settings() diff --git a/src/stale/hyperglass/hyperglass/state/__init__.py b/src/stale/hyperglass/hyperglass/state/__init__.py deleted file mode 100644 index dd8cc76..0000000 --- a/src/stale/hyperglass/hyperglass/state/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""hyperglass global state management.""" - -# Local -from .hooks import use_state -from .store import HyperglassState - -__all__ = ( - "use_state", - "HyperglassState", -) diff --git a/src/stale/hyperglass/hyperglass/state/hooks.py b/src/stale/hyperglass/hyperglass/state/hooks.py deleted file mode 100644 index 1076553..0000000 --- a/src/stale/hyperglass/hyperglass/state/hooks.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Hooks for accessing hyperglass global state.""" - -# Standard Library -import typing as t -from functools import lru_cache - -# Project -from hyperglass.exceptions.private import StateError - -# Local -from .store import HyperglassState -from ..settings import Settings - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.ui import UIParameters - from hyperglass.models.directive import Directives - from hyperglass.models.config.params import Params - from hyperglass.models.config.devices import Devices - - # Local - from .redis import RedisManager - - -@lru_cache -def _use_state(attr: t.Optional[str] = None) -> "HyperglassState": - """Get hyperglass state by property. - - Implemented separately due to typing issues related to lru_cache described here: - https://github.com/python/mypy/issues/8356 - https://github.com/python/mypy/issues/9112 - """ - if attr is None: - return HyperglassState(settings=Settings) - if attr in ("cache", "redis"): - return HyperglassState(settings=Settings).cache - if attr in HyperglassState.properties(): - return getattr(HyperglassState(settings=Settings), attr) - raise StateError("'{attr}' does not exist on HyperglassState", attr=attr) - - -@t.overload -def use_state(attr: t.Literal["params"]) -> "Params": - """Access hyperglass configuration parameters from global state.""" - - -@t.overload -def use_state(attr: t.Literal["devices"]) -> "Devices": - """Access hyperglass devices from global state.""" - - -@t.overload -def use_state(attr: t.Literal["ui_params"]) -> "UIParameters": - """Access hyperglass UI parameters from global state.""" - - -@t.overload -def use_state(attr: t.Literal["cache", "redis"]) -> "RedisManager": - """Directly access hyperglass Redis cache manager.""" - - -@t.overload -def use_state(attr: t.Literal["directives"]) -> "Directives": - """Access all hyperglass directives.""" - - -@t.overload -def use_state(attr=None) -> "HyperglassState": - """Access entire global state. - - This overload needs to be defined last since it's a catchall. - """ - - -def use_state(attr: t.Optional[str] = None) -> "HyperglassState": - """Access global hyperglass state.""" - return _use_state(attr) diff --git a/src/stale/hyperglass/hyperglass/state/manager.py b/src/stale/hyperglass/hyperglass/state/manager.py deleted file mode 100644 index c143b10..0000000 --- a/src/stale/hyperglass/hyperglass/state/manager.py +++ /dev/null @@ -1,53 +0,0 @@ -"""hyperglass global state.""" - -# Standard Library -import typing as t - -# Third Party -from redis import Redis, ConnectionPool - -# Project -from hyperglass.util import repr_from_attrs - -# Local -from .redis import RedisManager - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.system import HyperglassSettings - - -class StateManager: - """Global State Manager. - - Maintains configuration objects in Redis cache and accesses them as needed. - """ - - settings: "HyperglassSettings" - redis: RedisManager - _namespace: str = "hyperglass.state" - - def __init__(self, *, settings: "HyperglassSettings") -> None: - """Set up Redis connection and add configuration objects.""" - - self.settings = settings - connection_pool = ConnectionPool.from_url(**self.settings.redis_connection_pool) - redis = Redis(connection_pool=connection_pool) - self.redis = RedisManager(instance=redis, namespace=self._namespace) - - def __repr__(self) -> str: - """Represent state manager by name and namespace.""" - return repr_from_attrs(self, ("redis", "namespace")) - - def __str__(self) -> str: - """Represent state manager by __repr__.""" - return repr(self) - - @classmethod - def properties(cls: "StateManager") -> t.Tuple[str, ...]: - """Get all read-only properties of the state manager.""" - return tuple( - attr - for attr in dir(cls) - if not attr.startswith("_") and "fget" in dir(getattr(cls, attr)) - ) diff --git a/src/stale/hyperglass/hyperglass/state/redis.py b/src/stale/hyperglass/hyperglass/state/redis.py deleted file mode 100644 index 33fc0ae..0000000 --- a/src/stale/hyperglass/hyperglass/state/redis.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Interact with redis for state management.""" - -# Standard Library -import pickle -import typing as t -from types import TracebackType -from typing import overload -from datetime import datetime, timedelta - -# Project -from hyperglass.log import log -from hyperglass.exceptions.private import StateError - -if t.TYPE_CHECKING: - # Third Party - from redis import Redis - from redis.client import Pipeline - - -class RedisManager: - """Convenience wrapper for managing a redis session.""" - - instance: "Redis" - namespace: str - - def __init__(self, instance: "Redis", namespace: str) -> None: - """Set up Redis connection and add configuration objects.""" - self.instance = instance - self.namespace = namespace - - def __repr__(self) -> str: - """Alias repr to Redis instance's repr.""" - return repr(self.instance) - - def __str__(self) -> str: - """String-friendly redis manager.""" - return repr(self) - - def _key_join(self, *keys: str) -> str: - """Format keys with state namespace.""" - key_in_parts = (k for key in keys for k in key.split(".")) - key_parts = list(dict.fromkeys((*self.namespace.split("."), *key_in_parts))) - return ".".join(key_parts) - - def key(self, key: t.Union[str, t.Sequence[str]]) -> str: - """Format keys with state namespace.""" - if isinstance(key, (t.List, t.Tuple, t.Generator)): - return self._key_join(*key) - return self._key_join(key) - - def check(self) -> bool: - """Ensure the redis instance is running and reachable.""" - result = self.instance.ping() - if result is False: - raise RuntimeError( - "Redis instance {!r} is not running or reachable".format(self.instance) - ) - return result - - def delete(self, key: t.Union[str, t.Sequence[str]]) -> None: - """Delete a key and value from the cache.""" - self.instance.delete(self.key(key)) - - def expire( - self, - key: t.Union[str, t.Sequence[str]], - *, - expire_in: t.Optional[t.Union[timedelta, int]] = None, - expire_at: t.Optional[t.Union[datetime, int]] = None, - ) -> None: - """Expire a cache key, either at a time, or in a number of seconds. - - If no at or in time is specified, the key is deleted. - """ - key = self.key(key) - if isinstance(expire_at, (datetime, int)): - self.instance.expireat(key, expire_at) - return - if isinstance(expire_in, (timedelta, int)): - self.instance.expire(key, expire_in) - return - self.instance.delete(key) - - def get( - self, - key: t.Union[str, t.Sequence[str]], - *, - raise_if_none: bool = False, - value_if_none: t.Any = None, - ) -> t.Union[None, t.Any]: - """Get and decode a value from the cache.""" - name = self.key(key) - value: t.Optional[bytes] = self.instance.get(name) - if isinstance(value, bytes): - return pickle.loads(value) # noqa - if raise_if_none is True: - raise StateError("'{key}' ('{name}') does not exist in Redis store", key=key, name=name) - if value_if_none is not None: - return value_if_none - return None - - def set(self, key: t.Union[str, t.Sequence[str]], value: t.Any) -> None: - """Add an object to the cache.""" - name = self.key(key) - self.instance.set(name, pickle.dumps(value)) - - @overload - def get_map(self, key: str, item: str) -> t.Any: - """Get a single value from a Redis hash map (dict).""" - - @overload - def get_map(self, key: str, item=None) -> t.Any: - """Get a single value from a Redis hash map (dict).""" - - def get_map(self, key: str, item: t.Optional[str] = None) -> t.Any: - """Get a Redis hash map or hash map value.""" - name = self.key(key) - if isinstance(item, str): - value = self.instance.hget(name, item) - else: - value = self.instance.hgetall(name) - - if isinstance(value, bytes): - return pickle.loads(value) # noqa - return None - - def set_map_item(self, key: str, item: str, value: t.Any) -> None: - """Add a value to a hash map (dict).""" - name = self.key(key) - self.instance.hset(name, item, pickle.dumps(value)) - - def pipeline(self): - """Enter a Redis Pipeline, but expose all the custom interaction methods.""" - # Copy the base RedisManager and remove the pipeline method (this method). - ctx = type( - "RedisManagerExcludePipeline", - (RedisManager,), - {k: v for k, v in self.__dict__.items() if k != "pipeline"}, - ) - - def nested_pipeline(*_, **__) -> None: - """Ensure pipeline is never called from within pipeline.""" - raise AttributeError("Cannot access pipeline from pipeline") - - class RedisManagerPipeline(ctx): - """Copy of RedisManager, but uses `Redis.pipeline` as the `instance`.""" - - parent: "Redis" - instance: "Pipeline" - pipeline: t.Any = nested_pipeline - - def __init__( - pipeline_self, # noqa: N805 Avoid `self` namespace conflict - *, - parent: "Redis", - instance: "Pipeline", - namespace: str, - ) -> None: - pipeline_self.parent = parent - super().__init__(instance=instance, namespace=namespace) - - def __enter__( - pipeline_self: "RedisManagerPipeline", # noqa: N805 Avoid `self` namespace conflict - ) -> "RedisManagerPipeline": - return pipeline_self - - def __exit__( - pipeline_self: "RedisManagerPipeline", # noqa: N805 Avoid `self` namespace conflict - exc_type: t.Optional[t.Type[BaseException]] = None, - exc_value: t.Optional[BaseException] = None, - _: t.Optional[TracebackType] = None, - ) -> None: - pipeline_self.instance.execute() - if exc_type is not None: - log.bind( - pipeline=repr(pipeline_self), - parent=repr(pipeline_self.parent), - error=exc_value, - ).error( - "Error exiting pipeline", - ) - - return RedisManagerPipeline( - parent=self.instance, - instance=self.instance.pipeline(), - namespace=self.namespace, - ) diff --git a/src/stale/hyperglass/hyperglass/state/store.py b/src/stale/hyperglass/hyperglass/state/store.py deleted file mode 100644 index 9414adc..0000000 --- a/src/stale/hyperglass/hyperglass/state/store.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Primary state container.""" - -# Standard Library -import typing as t - -# Local -from .manager import StateManager - -if t.TYPE_CHECKING: - # Project - from hyperglass.models.ui import UIParameters - from hyperglass.plugins._base import HyperglassPlugin - from hyperglass.models.directive import Directive, Directives - from hyperglass.models.config.params import Params - from hyperglass.models.config.devices import Devices - - # Local - from .manager import RedisManager - - -PluginT = t.TypeVar("PluginT", bound="HyperglassPlugin") - - -class HyperglassState(StateManager): - """Primary hyperglass state container.""" - - def add_plugin(self, _type: str, plugin: "HyperglassPlugin") -> None: - """Add a plugin to its list by type.""" - current = self.plugins(_type) - self.redis.set(("plugins", _type), list({*current, plugin})) - - def remove_plugin(self, _type: str, plugin: "HyperglassPlugin") -> None: - """Remove a plugin from its list by type.""" - current = self.plugins(_type) - plugins = {p for p in current if p != plugin} - self.redis.set(("plugins", _type), list(plugins)) - - def reset_plugins(self, _type: str) -> None: - """Remove all plugins of `_type`.""" - self.redis.set(("plugins", _type), []) - - def add_directive(self, *directives: t.Union["Directive", t.Dict[str, t.Any]]) -> None: - """Add a directive.""" - current = self.directives - current.add(*directives, unique_by="id") - self.redis.set("directives", current) - - def clear(self) -> None: - """Delete all cache keys.""" - self.redis.instance.flushdb(asynchronous=True) - - @property - def cache(self) -> "RedisManager": - """Get the redis manager instance.""" - return self.redis - - @property - def params(self) -> "Params": - """Get hyperglass configuration parameters (`hyperglass.yaml`).""" - return self.redis.get("params", raise_if_none=True) - - @property - def devices(self) -> "Devices": - """Get hyperglass devices (`devices.yaml`).""" - return self.redis.get("devices", raise_if_none=True) - - @property - def ui_params(self) -> "UIParameters": - """UI parameters, built from params.""" - return self.redis.get("ui_params", raise_if_none=True) - - @property - def directives(self) -> "Directives": - """All directives.""" - return self.redis.get("directives", raise_if_none=True) - - def plugins(self, _type: str) -> t.List[PluginT]: - """Get plugins by type.""" - return self.redis.get(("plugins", _type), raise_if_none=False, value_if_none=[]) diff --git a/src/stale/hyperglass/hyperglass/state/tests/__init__.py b/src/stale/hyperglass/hyperglass/state/tests/__init__.py deleted file mode 100644 index b8e28d4..0000000 --- a/src/stale/hyperglass/hyperglass/state/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""State tests.""" diff --git a/src/stale/hyperglass/hyperglass/state/tests/test_hooks.py b/src/stale/hyperglass/hyperglass/state/tests/test_hooks.py deleted file mode 100644 index e82f1a4..0000000 --- a/src/stale/hyperglass/hyperglass/state/tests/test_hooks.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Test state hooks.""" - -# Standard Library -import typing as t - -# Third Party -import pytest - -if t.TYPE_CHECKING: - from hyperglass.state import HyperglassState - -# Project -from hyperglass.models.ui import UIParameters -from hyperglass.configuration import init_ui_params -from hyperglass.models.directive import Directives -from hyperglass.models.config.params import Params -from hyperglass.models.config.devices import Devices - -# Local -from ..hooks import use_state -from ..store import HyperglassState - -STATE_ATTRS = ( - ("params", Params), - ("devices", Devices), - ("ui_params", UIParameters), - ("directives", Directives), - (None, HyperglassState), -) - - -@pytest.fixture -def params(): - return {} - - -@pytest.fixture -def devices(): - return [ - { - "name": "test1", - "address": "127.0.0.1", - "credential": {"username": "", "password": ""}, - "platform": "juniper", - "attrs": {"source4": "192.0.2.1", "source6": "2001:db8::1"}, - "directives": ["juniper_bgp_route"], - } - ] - - -@pytest.fixture -def directives(): - return [ - { - "juniper_bgp_route": { - "name": "BGP Route", - "field": {"description": "test"}, - } - } - ] - - -@pytest.fixture -def state( - *, - params: t.Dict[str, t.Any], - directives: t.Sequence[t.Dict[str, t.Any]], - devices: t.Sequence[t.Dict[str, t.Any]], -) -> t.Generator["HyperglassState", None, None]: - """Test fixture to initialize Redis store.""" - _state = use_state() - _params = Params(**params) - _directives = Directives.new(*directives) - - with _state.cache.pipeline() as pipeline: - # Write params and directives to the cache first to avoid a race condition where ui_params - # or devices try to access params or directives before they're available. - pipeline.set("params", _params) - pipeline.set("directives", _directives) - - _devices = Devices(*devices) - ui_params = init_ui_params(params=_params, devices=_devices) - - with _state.cache.pipeline() as pipeline: - pipeline.set("devices", _devices) - pipeline.set("ui_params", ui_params) - - yield _state - _state.clear() - - -def test_use_state_caching(state): - first = None - for attr, model in STATE_ATTRS: - for i in range(0, 5): - instance = use_state(attr) - if i == 0: - first = instance - 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/src/stale/hyperglass/hyperglass/types.py b/src/stale/hyperglass/hyperglass/types.py deleted file mode 100644 index 6bfd9ca..0000000 --- a/src/stale/hyperglass/hyperglass/types.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Custom types.""" - -# Standard Library -import typing as _t - -_S = _t.TypeVar("_S") - -Series = _t.Union[_t.MutableSequence[_S], _t.Tuple[_S], _t.Set[_S]] -"""Like `typing.Sequence`, but excludes `str`.""" diff --git a/src/stale/hyperglass/hyperglass/util/__init__.py b/src/stale/hyperglass/hyperglass/util/__init__.py deleted file mode 100644 index 06699b4..0000000 --- a/src/stale/hyperglass/hyperglass/util/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Utility functions.""" - -# Local -from .files import copyfiles, check_path, move_files, dotenv_to_dict -from .tools import ( - at_least, - compare_init, - get_fmt_keys, - compare_dicts, - compare_lists, - dict_to_kwargs, - snake_to_camel, - parse_exception, - repr_from_attrs, - deep_convert_keys, - split_on_uppercase, - run_coroutine_in_new_thread, -) -from .typing import is_type, is_series -from .validation import get_driver, resolve_hostname, validate_platform -from .system_info import cpu_count, check_python, get_system_info, get_node_version - -__all__ = ( - "at_least", - "check_path", - "check_python", - "compare_dicts", - "compare_init", - "compare_lists", - "copyfiles", - "cpu_count", - "deep_convert_keys", - "dict_to_kwargs", - "dotenv_to_dict", - "get_driver", - "get_fmt_keys", - "get_node_version", - "get_system_info", - "is_series", - "is_type", - "move_files", - "parse_exception", - "repr_from_attrs", - "resolve_hostname", - "run_coroutine_in_new_thread", - "snake_to_camel", - "split_on_uppercase", - "validate_platform", -) diff --git a/src/stale/hyperglass/hyperglass/util/docs.py b/src/stale/hyperglass/hyperglass/util/docs.py deleted file mode 100644 index 1aa5336..0000000 --- a/src/stale/hyperglass/hyperglass/util/docs.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Helpers for hyperglass docs.""" - -# Standard Library -import json -import typing as t -from pathlib import Path -from importlib.util import module_from_spec, spec_from_file_location - - -class PlatformSpec(t.TypedDict): - """Definition for each platform.""" - - name: str - keys: t.Tuple[str, ...] - native: bool - - -def get_directive_variable(path: Path, variable: str) -> t.Any: - """Read a variable from a directive file.""" - - name, _ = path.name.split(".") - spec = spec_from_file_location(name, location=path) - module = module_from_spec(spec) - spec.loader.exec_module(module) - - exports = tuple(getattr(module, e, None) for e in dir(module) if e == variable) - if len(exports) < 1: - raise RuntimeError(f"'{path!s} exists', but it is missing a variable named '{variable}'") - - value, *_ = exports - return value - - -def create_platform_list() -> str: - """Create a list of platforms as a JSON file for use by the docs.""" - # Third Party - from netmiko.ssh_dispatcher import CLASS_MAPPER # type: ignore - - project_root = Path(__file__).parent.parent.parent - - dir_ = project_root / "docs" - file_ = dir_ / "platforms.json" - - builtin_directives = project_root / "hyperglass" / "defaults" / "directives" - - platforms: t.Tuple[PlatformSpec] = () - - keys = [] - - for path in builtin_directives.iterdir(): - if not path.name.startswith("_"): - name = get_directive_variable(path, "NAME") - if not isinstance(name, str): - raise RuntimeError("'NAME' variable is missing or invalid in '{!s}'".format(path)) - _platforms = get_directive_variable(path, "PLATFORMS") - if not isinstance(_platforms, t.Tuple, t.List): - raise RuntimeError( - "'PLATFORMS' variable is missing or invalid in '{!s}'".format(path) - ) - spec: PlatformSpec = {"name": name, "keys": _platforms, "native": True} - platforms += (spec,) - keys = [*keys, *_platforms] - - for key in CLASS_MAPPER.keys(): - if key not in keys: - spec: PlatformSpec = {"name": "", "keys": (key,), "native": False} - platforms += (spec,) - - sorted_platforms = list(platforms) - sorted_platforms.sort(key=lambda x: x["keys"][0]) - sorted_platforms.sort(key=lambda x: not x["native"]) - - with file_.open("w+") as opened_file: - json.dump(sorted_platforms, opened_file) - - return f"Wrote platforms to {file_!s}" diff --git a/src/stale/hyperglass/hyperglass/util/files.py b/src/stale/hyperglass/hyperglass/util/files.py deleted file mode 100644 index 176d0be..0000000 --- a/src/stale/hyperglass/hyperglass/util/files.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Utilities for working with files.""" - -# Standard Library -import shutil -import typing as t -from queue import Queue -from pathlib import Path -from threading import Thread - - -async def move_files(src: Path, dst: Path, files: t.Iterable[Path]) -> t.Tuple[str]: # noqa: C901 - """Move iterable of files from source to destination.""" - - # Project - from hyperglass.log import log - - def error(*args, **kwargs): - msg = ", ".join(args) - kwargs = {k: str(v) for k, v in kwargs.items()} - error_msg = msg.format(**kwargs) - log.error(error_msg) - return RuntimeError(error_msg) - - if not isinstance(src, Path): - try: - src = Path(src) - except TypeError as err: - raise error("{p} is not a valid path", p=src) from err - - if not isinstance(dst, Path): - try: - dst = Path(dst) - except TypeError as err: - raise error("{p} is not a valid path", p=dst) from err - - if not isinstance(files, (t.List, t.Tuple, t.Generator)): - raise error( - "{fa} must be an iterable (list, tuple, or generator). Received {f}", - fa="Files argument", - f=files, - ) - - for path in (src, dst): - if not path.exists(): - raise error("{p} does not exist", p=path) - - migrated = () - - for file in files: - dst_file = dst / file.name - - if not file.exists(): - raise error("{f} does not exist", f=file) - - try: - if not dst_file.exists(): - shutil.copyfile(file, dst_file) - migrated += (str(dst_file),) - except Exception as e: - raise error("Failed to migrate {f}: {e}", f=dst_file, e=e) from e - - return migrated - - -class FileCopy(Thread): - """Custom thread for copyfiles() function.""" - - def __init__(self, src: Path, dst: Path, queue: Queue): - """Initialize custom thread.""" - super().__init__() - - if not src.exists(): - raise ValueError("{} does not exist", str(src)) - - self.src = src - self.dst = dst - self.queue = queue - - def run(self): - """Put one object into the queue for each file.""" - try: - try: - shutil.copy(self.src, self.dst) - except IOError as err: - self.queue.put(err) - else: - self.queue.put(self.src) - finally: - pass - - -def copyfiles(src_files: t.Iterable[Path], dst_files: t.Iterable[Path]): - """Copy iterable of files from source to destination with threading.""" - - # Project - from hyperglass.log import log - - queue = Queue() - threads = () - src_files_len = len(src_files) - dst_files_len = len(dst_files) - - if src_files_len != dst_files_len: - raise ValueError( - "The number of source files " - + "({}) must match the number of destination files ({}).".format( - src_files_len, dst_files_len - ) - ) - - for i, file_ in enumerate(src_files): - file_thread = FileCopy(src=file_, dst=dst_files[i], queue=queue) - threads += (file_thread,) - - for thread in threads: - thread.start() - - for _ in src_files: - copied = queue.get() - log.bind(path=copied).debug("Copied file", path=copied) - - for thread in threads: - thread.join() - - for i, file_ in enumerate(dst_files): - if not file_.exists(): - raise RuntimeError("{!s} was not copied to {!s}", src_files[i], file_) - - return True - - -def check_path( - path: t.Union[Path, str], *, mode: str = "r", create: bool = False -) -> t.Optional[Path]: - """Verify if a path exists and is accessible.""" - - result = None - - if not isinstance(path, Path): - path = Path(path) - - if not path.exists(): - if create: - if path.is_file(): - path.parent.mkdir(parents=True) - else: - path.mkdir(parents=True) - else: - raise FileNotFoundError(f"{str(path)} does not exist.") - - if path.exists(): - if path.is_file(): - with path.open(mode): - result = path - else: - result = path - - return result - - -def dotenv_to_dict(dotenv: t.Union[Path, str]) -> t.Dict[str, str]: - """Convert a .env file to a Python dict.""" - if not isinstance(dotenv, (Path, str)): - raise TypeError("Argument 'file' must be a Path object or string") - result = {} - data = "" - if isinstance(dotenv, Path): - if not dotenv.exists(): - raise FileNotFoundError("{!r} does not exist", str(dotenv)) - with dotenv.open("r") as f: - data = f.read() - else: - data = dotenv - - for line in (line for line in (line.strip() for line in data.splitlines()) if line): - parts = line.split("=") - if len(parts) != 2: - raise TypeError( - f"Line {line!r} is improperly formatted. " - "Expected a key/value pair such as 'key=value'" - ) - key, value = line.split("=") - result[key.strip()] = value.strip() - - return result diff --git a/src/stale/hyperglass/hyperglass/util/system_info.py b/src/stale/hyperglass/hyperglass/util/system_info.py deleted file mode 100644 index c8f28c3..0000000 --- a/src/stale/hyperglass/hyperglass/util/system_info.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Utility functions for gathering system information.""" - -# Standard Library -import os -import sys -import typing as t -import platform - -# Third Party -import psutil as _psutil -from cpuinfo import get_cpu_info as _get_cpu_info # type: ignore - -# Project -from hyperglass.constants import __version__ - -SystemData = t.Dict[str, t.Tuple[t.Union[str, int], str]] - - -def _cpu() -> SystemData: - """Construct CPU Information.""" - cpu_info = _get_cpu_info() - brand = cpu_info.get("brand_raw", "") - cores_logical = _psutil.cpu_count() - cores_raw = _psutil.cpu_count(logical=False) - # TODO: this is currently broken for M1 Macs, check status of: https://github.com/giampaolo/psutil/issues/1892 - cpu_ghz = _psutil.cpu_freq().current / 1000 - return (brand, cores_logical, cores_raw, cpu_ghz) - - -def _memory() -> SystemData: - """Construct RAM Information.""" - mem_info = _psutil.virtual_memory() - total_gb = round(mem_info.total / 1e9, 2) - usage_percent = mem_info.percent - return (total_gb, usage_percent) - - -def _disk() -> SystemData: - """Construct Disk Information.""" - disk_info = _psutil.disk_usage("/") - total_gb = round(disk_info.total / 1e9, 2) - usage_percent = disk_info.percent - return (total_gb, usage_percent) - - -def get_node_version() -> t.Tuple[int, int, int]: - """Get the system's NodeJS version.""" - - # Standard Library - import shutil - import subprocess - - node_path = shutil.which("node") - - raw_version = subprocess.check_output([node_path, "--version"]).decode() # noqa: S603 - - # Node returns the version as 'v14.5.0', for example. Remove the v. - version = raw_version.replace("v", "") - # Parse the version parts. - return tuple((int(v) for v in version.split("."))) - - -def cpu_count(multiplier: int = 0) -> int: - """Get server's CPU core count. - - Used to determine the number of web server workers. - """ - # Standard Library - import multiprocessing - - return multiprocessing.cpu_count() * multiplier - - -def check_python() -> str: - """Verify Python Version.""" - # Project - from hyperglass.constants import MIN_PYTHON_VERSION - - pretty_version = ".".join(tuple(str(v) for v in MIN_PYTHON_VERSION)) - running_version = ".".join( - str(v) for v in (sys.version_info.major, sys.version_info.minor, sys.version_info.micro) - ) - if sys.version_info < MIN_PYTHON_VERSION: - raise RuntimeError(f"Python {pretty_version}+ is required (Running {running_version})") - return running_version - - -def get_system_info() -> SystemData: - """Get system info.""" - - cpu_info, cpu_logical, cpu_physical, cpu_speed = _cpu() - mem_total, mem_usage = _memory() - disk_total, disk_usage = _disk() - - return { - "hyperglass Version": (__version__, "text"), - "hyperglass Path": (os.environ["hyperglass_directory"], "code"), - "Python Version": (platform.python_version(), "code"), - "Node Version": (".".join(str(v) for v in get_node_version()), "code"), - "Platform Info": (platform.platform(), "code"), - "CPU Info": (cpu_info, "text"), - "Logical Cores": (cpu_logical, "code"), - "Physical Cores": (cpu_physical, "code"), - "Processor Speed": (f"{cpu_speed}GHz", "code"), - "Total Memory": (f"{mem_total} GB", "text"), - "Memory Utilization": (f"{mem_usage}%", "text"), - "Total Disk Space": (f"{disk_total} GB", "text"), - "Disk Utilization": (f"{disk_usage}%", "text"), - } diff --git a/src/stale/hyperglass/hyperglass/util/tests/__init__.py b/src/stale/hyperglass/hyperglass/util/tests/__init__.py deleted file mode 100644 index 9667a75..0000000 --- a/src/stale/hyperglass/hyperglass/util/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""hyperglass.util tests.""" diff --git a/src/stale/hyperglass/hyperglass/util/tests/test_files.py b/src/stale/hyperglass/hyperglass/util/tests/test_files.py deleted file mode 100644 index 439f042..0000000 --- a/src/stale/hyperglass/hyperglass/util/tests/test_files.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Test file-related utilities.""" - -# Standard Library -import string -import secrets -from pathlib import Path - -# Third Party -import pytest - -# Local -from ..files import copyfiles, check_path, move_files, dotenv_to_dict - -ENV_TEST = """KEY1=VALUE1 -KEY2=VALUE2 -KEY3=VALUE3 - """ - - -def _random_string(length: int) -> str: - alphabet = string.ascii_letters + string.digits - result = "".join(secrets.choice(alphabet) for i in range(length)) - return result - - -def test_dotenv_to_dict_string(): - result = dotenv_to_dict(ENV_TEST) - assert result.get("KEY1") == "VALUE1" - assert result.get("KEY2") == "VALUE2" - assert result.get("KEY3") == "VALUE3" - - -def test_dotenv_to_dict_file(tmp_path_factory: pytest.TempPathFactory): - dirname = tmp_path_factory.mktemp("dotenv") - file_ = dirname / "test_dotenv_to_dict_file.env" - with file_.open("w+") as f: - f.write(ENV_TEST) - result = dotenv_to_dict(file_) - assert result.get("KEY1") == "VALUE1" - assert result.get("KEY2") == "VALUE2" - assert result.get("KEY3") == "VALUE3" - - -def test_dotenv_to_dict_raises_type_error(): - with pytest.raises(TypeError): - dotenv_to_dict(True) - - -def test_dotenv_to_dict_raises_filenotfounderror(): - with pytest.raises(FileNotFoundError): - dotenv_to_dict(Path("/tmp/not-a-thing")) # noqa: S108 - - -def test_dotenv_invalid_format(): - with pytest.raises(TypeError): - dotenv_to_dict("this should raise an error") - - -def test_check_path_file(tmp_path_factory: pytest.TempPathFactory): - dir_ = tmp_path_factory.mktemp("test") - file_ = dir_ / "file.txt" - file_.touch() - result = check_path(file_) - assert result == file_ - - -def test_check_path_dir(tmp_path_factory: pytest.TempPathFactory): - dir_ = tmp_path_factory.mktemp("test") - child = dir_ / "child_dir" - child.mkdir() - result = check_path(child) - assert child.exists() - assert result == child - - -def test_check_path_create_file(tmp_path_factory: pytest.TempPathFactory): - dir_ = tmp_path_factory.mktemp("test") - file_ = dir_ / "file.txt" - result = check_path(file_, create=True) - assert file_.exists() - assert result == file_ - - -def test_check_path_create_dir(tmp_path_factory: pytest.TempPathFactory): - dir_ = tmp_path_factory.mktemp("test") - child = dir_ / "child_dir" - result = check_path(child, create=True) - assert child.exists() - assert result == child - - -def test_check_path_raises(tmp_path_factory: pytest.TempPathFactory): - dir_ = tmp_path_factory.mktemp("test") - file_ = dir_ / "file.txt" - with pytest.raises(FileNotFoundError): - check_path(file_, create=False) - - -@pytest.mark.asyncio -async def test_move_files(tmp_path_factory: pytest.TempPathFactory): - src = tmp_path_factory.mktemp("src") - dst = tmp_path_factory.mktemp("dst") - filenames = ("".join(_random_string(8)) for _ in range(10)) - files = [src / name for name in filenames] - [f.touch() for f in files] - result = await move_files(src, dst, files) - dst_files = sorted([str(c) for c in dst.iterdir()]) - result_files = sorted(result) - assert result_files == dst_files - - -@pytest.mark.asyncio -async def test_move_files_raise(tmp_path_factory: pytest.TempPathFactory): - src = tmp_path_factory.mktemp("src") - dst = tmp_path_factory.mktemp("dst") - filenames = ("".join(_random_string(8)) for _ in range(10)) - files = [src / name for name in filenames] - with pytest.raises(RuntimeError): - await move_files(src, dst, files) - - -def test_copyfiles(tmp_path_factory: pytest.TempPathFactory): - src = tmp_path_factory.mktemp("src") - dst = tmp_path_factory.mktemp("dst") - filenames = ["".join(_random_string(8)) for _ in range(10)] - src_files = [src / name for name in filenames] - dst_files = [dst / name for name in filenames] - [f.touch() for f in src_files] - result = copyfiles(src_files, dst_files) - assert result - - -def test_copyfiles_wrong_length(tmp_path_factory: pytest.TempPathFactory): - src = tmp_path_factory.mktemp("src") - dst = tmp_path_factory.mktemp("dst") - filenames = ["".join(_random_string(8)) for _ in range(10)] - dst_filenames = filenames[1:8] - src_files = [src / name for name in filenames] - dst_files = [dst / name for name in dst_filenames] - [f.touch() for f in src_files] - with pytest.raises(ValueError): - copyfiles(src_files, dst_files) diff --git a/src/stale/hyperglass/hyperglass/util/tests/test_tools.py b/src/stale/hyperglass/hyperglass/util/tests/test_tools.py deleted file mode 100644 index 6cf7686..0000000 --- a/src/stale/hyperglass/hyperglass/util/tests/test_tools.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Test generic utilities.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Local -from ..tools import ( - at_least, - compare_init, - get_fmt_keys, - compare_dicts, - compare_lists, - dict_to_kwargs, - snake_to_camel, - parse_exception, - repr_from_attrs, - deep_convert_keys, - split_on_uppercase, - run_coroutine_in_new_thread, -) - - -def test_split_on_uppercase(): - strings = ( - ("TestOne", ["Test", "One"]), - ("testTwo", ["test", "Two"]), - ("TestingOneTwoThree", ["Testing", "One", "Two", "Three"]), - ) - for str_in, list_out in strings: - result = split_on_uppercase(str_in) - assert result == list_out - - -def test_parse_exception(): - with pytest.raises(TypeError): - parse_exception(1) - - exc1 = RuntimeError("Test1") - exc1_expected = f"Runtime Error ({(RuntimeError.__doc__ or '').strip('.')})" - exc2 = RuntimeError("Test2") - exc2_cause = f"Connection Error ({(ConnectionError.__doc__ or '').strip('.')})" - exc2_expected = f"{exc1_expected}, caused by {exc2_cause}" - try: - raise exc1 - except Exception as err: - result = parse_exception(err) - assert result == exc1_expected - try: - raise exc2 from ConnectionError - except Exception as err: - result = parse_exception(err) - assert result == exc2_expected - - -def test_repr_from_attrs(): - # Third Party - from pydantic import create_model - - model = create_model("TestModel", one=(str, ...), two=(int, ...), three=(bool, ...)) - implementation = model(one="one", two=2, three=True) - result = repr_from_attrs(implementation, ("one", "two", "three")) - assert result == "TestModel(one='one', three=True, two=2)" - - -@pytest.mark.dependency() -def test_snake_to_camel(): - keys = ( - ("test_one", "testOne"), - ("test_two_three", "testTwoThree"), - ("Test_four_five_six", "testFourFiveSix"), - ) - for key_in, key_out in keys: - result = snake_to_camel(key_in) - assert result == key_out - - -def test_get_fmt_keys(): - template = "This is a {template} for a {test}" - result = get_fmt_keys(template) - assert len(result) == 2 and "template" in result and "test" in result - - -@pytest.mark.dependency( - depends=["hyperglass/util/tests/test_tools.py::test_snake_to_camel"], scope="session" -) -def test_deep_convert_keys(): - dict_in = { - "key_one": 1, - "key_two": 2, - "key_dict": { - "key_one": "one", - "key_two": "two", - }, - "key_list_dicts": [{"key_one": 101, "key_two": 102}, {"key_three": 103, "key_four": 104}], - } - - result = deep_convert_keys(dict_in, snake_to_camel) - assert result.get("keyOne") is not None - assert result.get("keyTwo") is not None - assert result.get("keyDict") is not None - assert result["keyDict"].get("keyOne") is not None - assert result["keyDict"].get("keyTwo") is not None - assert isinstance(result.get("keyListDicts"), list) - assert result["keyListDicts"][0].get("keyOne") is not None - assert result["keyListDicts"][0].get("keyTwo") is not None - assert result["keyListDicts"][1].get("keyThree") is not None - assert result["keyListDicts"][1].get("keyFour") is not None - - -def test_at_least(): - assert at_least(8, 10) == 10 - assert at_least(8, 6) == 8 - - -def test_compare_dicts(): - d1 = {"one": 1, "two": 2} - d2 = {"one": 1, "two": 2} - d3 = {"one": 1, "three": 3} - d4 = {"one": 1, "two": 3} - d5 = {} - d6 = {} - checks = ( - (d1, d2, True), - (d1, d3, False), - (d1, d4, False), - (d1, d1, True), - (d5, d6, True), - (d1, [], False), - ) - for a, b, expected in checks: - assert compare_dicts(a, b) is expected - - -def test_compare_init(): - class Compare1: - def __init__(self, item: str) -> None: - pass - - class Compare2: - def __init__(self: "Compare2", item: str) -> None: - pass - - class Compare3: - def __init__(self: "Compare3", item: str, other_item: int) -> None: - pass - - class Compare4: - def __init__(self: "Compare4", item: bool) -> None: - pass - - class Compare5: - pass - - checks = ( - (Compare1, Compare2, True), - (Compare1, Compare3, False), - (Compare1, Compare4, False), - (Compare1, Compare5, False), - (Compare1, Compare1, True), - ) - for a, b, expected in checks: - assert compare_init(a, b) is expected - - -def test_run_coroutine_in_new_thread(): - async def sleeper(): - await asyncio.sleep(5) - - async def test(): - return True - - asyncio.run(sleeper()) - result = run_coroutine_in_new_thread(test) - assert result is True - - -def test_compare_lists(): - # Standard Library - import random - - list1 = ["one", 2, "3"] - list2 = [4, "5", "six"] - list3 = ["one", 11, False] - list4 = [*list1, *list2] - random.shuffle(list4) - assert compare_lists(list1, list2) is False - assert compare_lists(list1, list3) is False - assert compare_lists(list1, list4) is True - - -def test_dict_to_kwargs(): - class Test: - one: int - two: int - - def __init__(self, **kw) -> None: - for k, v in kw.items(): - setattr(self, k, v) - - def __repr__(self) -> str: - return "Test(one={}, two={})".format(self.one, self.two) - - d1 = {"one": 1, "two": 2} - e1 = "one=1 two=2" - d2 = {"cls": Test(one=1, two=2), "three": "three"} - e2 = "cls=Test(one=1, two=2) three='three'" - r1 = dict_to_kwargs(d1) - assert r1 == e1 - r2 = dict_to_kwargs(d2) - assert r2 == e2 diff --git a/src/stale/hyperglass/hyperglass/util/tests/test_typing.py b/src/stale/hyperglass/hyperglass/util/tests/test_typing.py deleted file mode 100644 index 66fe8ca..0000000 --- a/src/stale/hyperglass/hyperglass/util/tests/test_typing.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Test typing utilities.""" -# flake8: noqa - -# Standard Library -import typing - -# Local -from ..typing import is_type, is_series - - -class EmptyTestClass: - pass - - -class EmptySubClass(EmptyTestClass): - pass - - -_string = "Test String" -_string_empty = "" -_dict = {"one": 1, "two": 2} -_dict_empty = dict() -_list = [1, 2, 3] -_list_empty = [] -_set = {"one", "two"} -_set_empty = set() -_tuple = (1, 2, 3) -_tuple_empty = tuple() -_class = EmptyTestClass -_class_instance = EmptyTestClass() -_subclass = EmptySubClass -_subclass_instance = EmptySubClass() - -DictOrString = typing.Union[typing.Dict, str] -ClassOrString = typing.Union[EmptyTestClass, str] - - -def test_is_type(): - checks = ( - ("Non-Empty String is String", True, _string, str), - ("Empty String is String", True, _string_empty, str), - ("Non-Empty Dict is Dict", True, _dict, typing.Dict), - ("Empty Dict is Dict", True, _dict_empty, dict), - ("Non-Empty List is List", True, _list, typing.List), - ("Empty List is List", True, _list_empty, list), - ("Non-Empty Tuple is Tuple", True, _tuple, typing.Tuple), - ("Empty Tuple is Tuple", True, _tuple_empty, tuple), - ("Non-Empty Set is Set", True, _set, typing.Set), - ("Empty Set is Set", True, _set_empty, set), - ("Non-Empty String is Dict or String", True, _string, DictOrString), - ("Non-Empty Dict is Dict or String", True, _dict, DictOrString), - ("Non-Empty List is Dict or String", False, _list, DictOrString), - ("Empty list is Dict or String", False, _list_empty, DictOrString), - ("Class object is Class object", False, _class, _class), - ("Class instance is Class instance", True, _class_instance, _class_instance), - ("Class instance is Class object", True, _class_instance, _class), - ("Subclass instance is Class instance", True, _subclass_instance, _class_instance), - ("Subclass instance is Class object", True, _subclass_instance, _class), - ("Class object is Class or String", False, _subclass, ClassOrString), - ("Class instance is Class or String", True, _class_instance, ClassOrString), - ("Subclass instance is Class or String", True, _subclass_instance, ClassOrString), - ) - for _, expected, value, _type in checks: - result = is_type(value, _type) - if result is not expected: - raise AssertionError(f"Got `{value}`, expected `{str(_type)}`") - - -def test_is_series(): - checks = ( - ((1, 2, 3), True), - ([1, 2, 3], True), - ("1,2,3", False), - ({1, 2, 3}, True), - ) - for value, expected in checks: - assert is_series(value) is expected diff --git a/src/stale/hyperglass/hyperglass/util/tools.py b/src/stale/hyperglass/hyperglass/util/tools.py deleted file mode 100644 index e58a3cb..0000000 --- a/src/stale/hyperglass/hyperglass/util/tools.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Collection of generalized functional tools.""" - -# Standard Library -import typing as t - -# Project -from hyperglass.types import Series - -DeepConvert = t.TypeVar("DeepConvert", bound=t.Dict[str, t.Any]) - - -def run_coroutine_in_new_thread(coroutine: t.Coroutine) -> t.Any: - """Run an async function in a separate thread and get the result.""" - # Standard Library - import asyncio - import threading - - class Resolver(threading.Thread): - def __init__(self, coro: t.Coroutine) -> None: - self.result: t.Any = None - self.coro: t.Coroutine = coro - super().__init__() - - def run(self): - self.result = asyncio.run(self.coro()) - - thread = Resolver(coroutine) - thread.start() - thread.join() - return thread.result - - -def split_on_uppercase(s: str) -> t.List[str]: - """Split characters by uppercase letters. - - From: https://stackoverflow.com/a/40382663 - """ - string_length = len(s) - is_lower_around = lambda: s[i - 1].islower() or string_length > (i + 1) and s[i + 1].islower() - - start = 0 - parts = [] - for i in range(1, string_length): - if s[i].isupper() and is_lower_around(): - parts.append(s[start:i]) - start = i - parts.append(s[start:]) - - return parts - - -def parse_exception(exc: BaseException) -> str: - """Parse an exception and its direct cause.""" - - if not isinstance(exc, BaseException): - raise TypeError(f"'{repr(exc)}' is not an exception.") - - def get_exc_name(exc): - return " ".join(split_on_uppercase(exc.__class__.__name__)) - - def get_doc_summary(doc): - return doc.strip().split("\n")[0].strip(".") - - name = get_exc_name(exc) - parsed = [] - if exc.__doc__: - detail = get_doc_summary(exc.__doc__) - parsed.append(f"{name} ({detail})") - else: - parsed.append(name) - - if exc.__cause__: - cause = get_exc_name(exc.__cause__) - if exc.__cause__.__doc__: - cause_detail = get_doc_summary(exc.__cause__.__doc__) - parsed.append(f"{cause} ({cause_detail})") - else: - parsed.append(cause) - return ", caused by ".join(parsed) - - -def repr_from_attrs(obj: object, attrs: Series[str], strip: t.Optional[str] = None) -> str: - """Generate a `__repr__()` value from a specific set of attribute names. - - Useful for complex models/objects where `__repr__()` should only display specific fields. - """ - # Check the object to ensure each attribute actually exists, and deduplicate - attr_names = {a for a in attrs if hasattr(obj, a)} - # Dict representation of attr name to obj value (e.g. `obj.attr`), if the value has a - # `__repr__` method. - attr_values = { - f if strip is None else f.strip(strip): v # noqa: IF100 - for f in attr_names - if hasattr((v := getattr(obj, f)), "__repr__") - } - pairs = (f"{k}={v!r}" for k, v in sorted(attr_values.items())) - return f"{obj.__class__.__name__}({', '.join(pairs)})" - - -def snake_to_camel(value: str) -> str: - """Convert a string from snake_case to camelCase.""" - head, *body = value.split("_") - humps = (hump.capitalize() for hump in body) - return "".join((head.lower(), *humps)) - - -def get_fmt_keys(template: str) -> t.List[str]: - """Get a list of str.format keys. - - For example, string `"The value of {key} is {value}"` returns - `["key", "value"]`. - """ - # Standard Library - import string - - keys = [] - for block in (b for b in string.Formatter.parse("", template) if isinstance(template, str)): - key = block[1] - if key: - keys.append(key) - return keys - - -def deep_convert_keys(_dict: t.Type[DeepConvert], predicate: t.Callable[[str], str]) -> DeepConvert: - """Convert all dictionary keys and nested dictionary keys.""" - converted = {} - - def get_value(value: t.Any): - if isinstance(value, t.Dict): - return {predicate(k): get_value(v) for k, v in value.items()} - if isinstance(value, t.List): - return [get_value(v) for v in value] - if isinstance(value, t.Tuple): - return tuple(get_value(v) for v in value) - return value - - for key, value in _dict.items(): - converted[predicate(key)] = get_value(value) - - return converted - - -def at_least( - minimum: int, - value: int, -) -> int: - """Get a number value that is at least a specified minimum.""" - if value < minimum: - return minimum - return value - - -def compare_dicts(dict_a: t.Dict[t.Any, t.Any], dict_b: t.Dict[t.Any, t.Any]) -> bool: - """Determine if two dictationaries are (mostly) equal.""" - if isinstance(dict_a, t.Dict) and isinstance(dict_b, t.Dict): - dict_a_keys, dict_a_values = set(dict_a.keys()), set(dict_a.values()) - dict_b_keys, dict_b_values = set(dict_b.keys()), set(dict_b.values()) - return all((dict_a_keys == dict_b_keys, dict_a_values == dict_b_values)) - return False - - -def compare_lists(left: t.List[t.Any], right: t.List[t.Any], *, ignore: Series[t.Any] = ()) -> bool: - """Determine if all items in left list exist in right list.""" - left_ignored = [i for i in left if i not in ignore] - diff_ignored = [i for i in left if i in right and i not in ignore] - return len(left_ignored) == len(diff_ignored) - - -def compare_init(obj_a: object, obj_b: object) -> bool: - """Compare the `__init__` annoations of two objects.""" - - def _check_obj(obj: object): - """Ensure `__annotations__` exists on the `__init__` method.""" - if hasattr(obj, "__init__") and isinstance(getattr(obj, "__init__", None), t.Callable): - if hasattr(obj.__init__, "__annotations__") and isinstance( - getattr(obj.__init__, "__annotations__", None), t.Dict - ): - return True - return False - - if all((_check_obj(obj_a), _check_obj(obj_b))): - obj_a.__init__.__annotations__.pop("self", None) - obj_b.__init__.__annotations__.pop("self", None) - return compare_dicts(obj_a.__init__.__annotations__, obj_b.__init__.__annotations__) - return False - - -def dict_to_kwargs(in_dict: t.Dict[str, t.Any]) -> str: - """Format a dict as a string of key/value pairs.""" - items = [] - for key, value in in_dict.items(): - out_str = f"{key}={value!r}" - items = [*items, out_str] - return " ".join(items) diff --git a/src/stale/hyperglass/hyperglass/util/typing.py b/src/stale/hyperglass/hyperglass/util/typing.py deleted file mode 100644 index c9cefbc..0000000 --- a/src/stale/hyperglass/hyperglass/util/typing.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Typing utilities.""" - -# Standard Library -import typing -import inspect - - -def is_type(value: typing.Any, *types: typing.Any) -> bool: - """Verify if the type of `value` matches any provided type in `types`. - - Will only check the main type for generics like `Dict` or `List`, but will check the individual - types of generics like `Union` or `Optional`. - - Probably wrong, but seems to work for most cases. - """ - for _type in types: - if _type is None: - return value is None - if inspect.isclass(_type): - return isinstance(value, _type) - origin = typing.get_origin(_type) - if origin is typing.Union: - return any(is_type(value, t) for t in _type.__args__) - if origin is None: - return isinstance(value, type(_type)) - return isinstance(value, origin) - return False - - -def is_series(value: typing.Any) -> bool: - """Determine if a value is a `hyperglass.types.Series`, i.e. non-string `typing.Sequence`.""" - if isinstance(value, (typing.MutableSequence, typing.Tuple, typing.Set)): - return True - return False diff --git a/src/stale/hyperglass/hyperglass/util/validation.py b/src/stale/hyperglass/hyperglass/util/validation.py deleted file mode 100644 index 7e685d6..0000000 --- a/src/stale/hyperglass/hyperglass/util/validation.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Validation Utilities.""" - -# Standard Library -import typing as t - -# Third Party -from netmiko.ssh_dispatcher import CLASS_MAPPER # type: ignore - -# Project -from hyperglass.constants import DRIVER_MAP - -if t.TYPE_CHECKING: - # Standard Library - from ipaddress import IPv4Address, IPv6Address - - -def validate_platform(_type: str) -> t.Tuple[bool, t.Union[None, str]]: - """Validate device type is supported.""" - - all_device_types = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()} - - result = (False, None) - - if _type in all_device_types: - result = (True, DRIVER_MAP.get(_type, "netmiko")) - - return result - - -def get_driver(_type: str, driver: t.Optional[str]) -> str: - """Determine the appropriate driver for a device.""" - - if driver is None: - # If no driver is set, use the driver map with netmiko as - # fallback. - return DRIVER_MAP.get(_type, "netmiko") - - all_drivers = {*DRIVER_MAP.values(), "netmiko"} - - if driver in all_drivers: - # If a driver is set and it is valid, allow it. - return driver - - # Otherwise, fail validation. - raise ValueError("{} is not a supported driver.".format(driver)) - - -def resolve_hostname( - hostname: str, -) -> t.Generator[t.Union["IPv4Address", "IPv6Address"], None, None]: - """Resolve a hostname via DNS/hostfile.""" - # Standard Library - from socket import gaierror, getaddrinfo - from ipaddress import ip_address - - # Project - from hyperglass.log import log - - log.bind(hostname=hostname).debug("Ensuring hostname is resolvable") - - ip4 = None - ip6 = None - try: - res = getaddrinfo(hostname, None) - for sock in res: - if sock[0].value == 2 and ip4 is None: - ip4 = ip_address(sock[4][0]) - elif sock[0].value in (10, 30) and ip6 is None: - ip6 = ip_address(sock[4][0]) - except (gaierror, ValueError, IndexError) as err: - log.debug(str(err)) - pass - - yield ip4 - yield ip6 diff --git a/src/stale/hyperglass/version.py b/src/stale/hyperglass/version.py deleted file mode 100755 index 99f337a..0000000 --- a/src/stale/hyperglass/version.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -"""Manage hyperglass version across multiple files.""" - -# Standard Library -import re -from typing import Tuple, Union, Pattern -from pathlib import Path - -# Third Party - - -PACKAGE_JSON = Path(__file__).parent / "hyperglass" / "ui" / "package.json" -PACKAGE_JSON_PATTERN = re.compile(r"\s+\"version\"\:\s\"(.+)\"\,$") - -PYPROJECT_TOML = Path(__file__).parent / "pyproject.toml" -PYPROJECT_PATTERN = re.compile(r"^version\s\=\s\"(.+)\"$") - -CONSTANTS = Path(__file__).parent / "hyperglass" / "constants.py" -CONSTANT_PATTERN = re.compile(r"^__version__\s\=\s\"(.+)\"$") - -UPGRADE_DOC = Path(__file__).parent / "docs" / "pages" / "installation" / "upgrading.mdx" -UPGRADE_DOC_PATTERN = re.compile(r"^git\scheckout\sv(.+)$") - -UPGRADE_GH_FEATURE = Path(__file__).parent / ".github" / "ISSUE_TEMPLATE" / "1-feature-request.yaml" -UPGRADE_GH_FEATURE_PATTERN = re.compile(r"^[\s\t]+placeholder\:\sv(.+)$") - -UPGRADE_GH_BUG = Path(__file__).parent / ".github" / "ISSUE_TEMPLATE" / "2-bug-report.yaml" - -UPGRADE_GH_BUG_PATTERN = re.compile(r"^[\s\t]+placeholder\:\sv(.+)$") - -UPGRADES = ( - ("package.json", PACKAGE_JSON, PACKAGE_JSON_PATTERN), - ("pyproject.toml", PYPROJECT_TOML, PYPROJECT_PATTERN), - ("constants.py", CONSTANTS, CONSTANT_PATTERN), - ("upgrading.mdx", UPGRADE_DOC, UPGRADE_DOC_PATTERN), - ("1-feature-request.yaml", UPGRADE_GH_FEATURE, UPGRADE_GH_FEATURE_PATTERN), - ("2-bug-report.yaml", UPGRADE_GH_BUG, UPGRADE_GH_BUG_PATTERN), -) - -# cli = typer.Typer(name="version", no_args_is_help=True) - - -class Version: - """Upgrade a file's version from one version to another.""" - - new_version: Union[str, int] - file: Path - line_pattern: Pattern[str] - old_version: Union[None, str, int] = None - _did_check: bool = False - _did_update: bool = False - - def __init__( - self, - *, - name: str, - new_version: Union[str, int], - line_pattern: Union[Pattern, str], - file: Union[Path, str], - ) -> None: - """Initialize version manager.""" - - self.name = name - self.new_version = new_version - - if isinstance(file, Path): - self.file = file - elif isinstance(file, str): - self.file = Path(file) - else: - raise TypeError(f"'{repr(file)}' must be a string or Path object") - - if isinstance(line_pattern, Pattern): - self.line_pattern = line_pattern - elif isinstance(line_pattern, str): - self.line_pattern = re.compile(line_pattern) - else: - raise TypeError(f"'{repr(line_pattern)}' is not a supported pattern") - - def __enter__(self) -> "Version": - """Exit context manager for 0.01% better DX.""" - return self - - def __exit__(self, *args, **kwargs) -> None: - """Exit context manager for 0.01% better DX.""" - pass - - def __str__(self) -> str: - """Represent the state and/or action taken.""" - if self._did_update: - old, new = self.upgrade_path - return f"Upgraded {self.name} from {old} → {new}" - if self._did_check: - return f"No update required for {self.name} from version {self.old_version}" - - return f"{self.name} has not been checked" - - def upgrade(self) -> None: - """Find a matching current version and upgrade it to the new version.""" - with self.file.open("r+") as file: - found_match = False - lines = file.readlines() - self._did_check = True - - for idx, line in enumerate(lines): - match = self.line_pattern.match(line) - if match: - old_version = match.group(1).strip() - try: - old_version = int(old_version) - except ValueError: - # Old version can't be converted to an integer, which is fine. - pass - self.old_version = old_version - - if self.old_version != self.new_version: - lines[idx] = re.sub(old_version, self.new_version, line) - found_match = True - break - - if found_match: - file.seek(0) - file.writelines(lines) - file.truncate() - self._did_update = True - - @property - def upgrade_path(self) -> Tuple[Union[str, int], Union[str, int]]: - """Get the old and new versions.""" - return (self.old_version, self.new_version) - - -def update_versions(new_version: str) -> None: - """Update hyperglass version in all package files.""" - for name, file, pattern in UPGRADES: - with Version( - name=name, - file=file, - line_pattern=pattern, - new_version=new_version, - ) as version: - version.upgrade() - typer.echo(str(version)) - - -