initial migration to litestar

This commit is contained in:
thatmattlove 2024-03-26 23:59:42 -04:00
parent 1ef376fb38
commit d2e1486b5a
19 changed files with 356 additions and 439 deletions

View file

@ -1,253 +1,62 @@
"""hyperglass REST API & Web UI.""" """hyperglass API."""
# Standard Library
import sys
from typing import List
from pathlib import Path
# Third Party # Third Party
from fastapi import FastAPI from litestar import Litestar
from fastapi.responses import JSONResponse from litestar.openapi import OpenAPIConfig
from fastapi.exceptions import ValidationException, RequestValidationError from litestar.exceptions import HTTPException, ValidationException
from fastapi.staticfiles import StaticFiles from litestar.static_files import create_static_files_router
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.openapi.utils import get_openapi
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
# Project # Project
from hyperglass.log import log
from hyperglass.util import cpu_count
from hyperglass.state import use_state from hyperglass.state import use_state
from hyperglass.constants import __version__ from hyperglass.constants import __version__
from hyperglass.models.ui import UIParameters
from hyperglass.api.events import on_startup, on_shutdown
from hyperglass.api.routes import docs, info, query, router, queries, routers, ui_props
from hyperglass.exceptions import HyperglassError from hyperglass.exceptions import HyperglassError
from hyperglass.api.error_handlers import (
app_handler, # Local
http_handler, from .events import check_redis
default_handler, from .routes import info, query, device, devices, queries
validation_handler, from .middleware import COMPRESSION_CONFIG, create_cors_config
) from .error_handlers import app_handler, http_handler, default_handler, validation_handler
from hyperglass.models.api.response import (
QueryError, __all__ = ("app",)
InfoResponse,
QueryResponse,
RoutersResponse,
SupportedQueryResponse,
)
STATE = use_state() STATE = use_state()
WORKING_DIR = Path(__file__).parent
EXAMPLES_DIR = WORKING_DIR / "examples"
UI_DIR = STATE.settings.static_path / "ui" UI_DIR = STATE.settings.static_path / "ui"
IMAGES_DIR = STATE.settings.static_path / "images" IMAGES_DIR = STATE.settings.static_path / "images"
EXAMPLE_DEVICES_PY = EXAMPLES_DIR / "devices.py"
EXAMPLE_QUERIES_PY = EXAMPLES_DIR / "queries.py"
EXAMPLE_QUERY_PY = EXAMPLES_DIR / "query.py"
EXAMPLE_DEVICES_CURL = EXAMPLES_DIR / "devices.sh"
EXAMPLE_QUERIES_CURL = EXAMPLES_DIR / "queries.sh"
EXAMPLE_QUERY_CURL = EXAMPLES_DIR / "query.sh"
ASGI_PARAMS = { OPEN_API = OpenAPIConfig(
"host": str(STATE.settings.host), title=STATE.params.docs.title.format(site_title=STATE.params.site_title),
"port": STATE.settings.port,
"debug": STATE.settings.debug,
"workers": cpu_count(2),
}
DOCS_PARAMS = {}
if STATE.params.docs.enable:
DOCS_PARAMS.update({"openapi_url": "/openapi.json"})
if STATE.params.docs.mode == "redoc":
DOCS_PARAMS.update({"docs_url": None, "redoc_url": STATE.params.docs.path})
elif STATE.params.docs.mode == "swagger":
DOCS_PARAMS.update({"docs_url": STATE.params.docs.path, "redoc_url": None})
for directory in (UI_DIR, IMAGES_DIR):
if not directory.exists():
log.warning("Directory '{d}' does not exist, creating...", d=str(directory))
directory.mkdir()
# Main App Definition
app = FastAPI(
debug=STATE.settings.debug,
title=STATE.params.site_title,
description=STATE.params.site_description,
version=__version__, version=__version__,
default_response_class=JSONResponse, description=STATE.params.docs.description,
**DOCS_PARAMS, path=STATE.params.docs.path,
root_schema_site="elements",
) )
# Add Event Handlers
for startup in on_startup:
app.add_event_handler("startup", startup)
for shutdown in on_shutdown: app = Litestar(
app.add_event_handler("shutdown", shutdown) route_handlers=[
device,
# HTTP Error Handler devices,
app.add_exception_handler(StarletteHTTPException, http_handler) queries,
info,
# Backend Application Error Handler query,
app.add_exception_handler(HyperglassError, app_handler) create_static_files_router(
path="/images", directories=[IMAGES_DIR], name="images", include_in_schema=False
# Request Validation Error Handler ),
app.add_exception_handler(RequestValidationError, validation_handler) create_static_files_router(
path="/", directories=[UI_DIR], name="ui", html_mode=True, include_in_schema=False
# App Validation Error Handler ),
app.add_exception_handler(ValidationException, validation_handler) ],
exception_handlers={
# Uncaught Error Handler HTTPException: http_handler,
app.add_exception_handler(Exception, default_handler) HyperglassError: app_handler,
ValidationException: validation_handler,
Exception: default_handler,
def _custom_openapi():
"""Generate custom OpenAPI config."""
openapi_schema = get_openapi(
title=STATE.params.docs.title.format(site_title=STATE.params.site_title),
version=__version__,
description=STATE.params.docs.description,
routes=app.routes,
)
openapi_schema["info"]["x-logo"] = {"url": "/images/light" + STATE.params.web.logo.light.suffix}
query_samples = []
queries_samples = []
devices_samples = []
with EXAMPLE_QUERY_CURL.open("r") as e:
example = e.read()
query_samples.append({"lang": "cURL", "source": example % str(STATE.params.docs.base_url)})
with EXAMPLE_QUERY_PY.open("r") as e:
example = e.read()
query_samples.append(
{"lang": "Python", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_DEVICES_CURL.open("r") as e:
example = e.read()
queries_samples.append(
{"lang": "cURL", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_DEVICES_PY.open("r") as e:
example = e.read()
queries_samples.append(
{"lang": "Python", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_QUERIES_CURL.open("r") as e:
example = e.read()
devices_samples.append(
{"lang": "cURL", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_QUERIES_PY.open("r") as e:
example = e.read()
devices_samples.append(
{"lang": "Python", "source": example % str(STATE.params.docs.base_url)}
)
openapi_schema["paths"]["/api/query/"]["post"]["x-code-samples"] = query_samples
openapi_schema["paths"]["/api/devices"]["get"]["x-code-samples"] = devices_samples
openapi_schema["paths"]["/api/queries"]["get"]["x-code-samples"] = queries_samples
app.openapi_schema = openapi_schema
return app.openapi_schema
CORS_ORIGINS = STATE.params.cors_origins.copy()
if STATE.settings.dev_mode:
CORS_ORIGINS = [*CORS_ORIGINS, STATE.settings.dev_url, "http://localhost:3000"]
# CORS Configuration
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
# GZIP Middleware
app.add_middleware(GZipMiddleware)
app.add_api_route(
path="/api/info",
endpoint=info,
methods=["GET"],
response_model=InfoResponse,
response_class=JSONResponse,
summary=STATE.params.docs.info.summary,
description=STATE.params.docs.info.description,
tags=[STATE.params.docs.info.title],
)
app.add_api_route(
path="/api/devices",
endpoint=routers,
methods=["GET"],
response_model=List[RoutersResponse],
response_class=JSONResponse,
summary=STATE.params.docs.devices.summary,
description=STATE.params.docs.devices.description,
tags=[STATE.params.docs.devices.title],
)
app.add_api_route(
path="/api/devices/{id}",
endpoint=router,
methods=["GET"],
response_model=RoutersResponse,
response_class=JSONResponse,
summary=STATE.params.docs.devices.summary,
description=STATE.params.docs.devices.description,
tags=[STATE.params.docs.devices.title],
)
app.add_api_route(
path="/api/queries",
endpoint=queries,
methods=["GET"],
response_class=JSONResponse,
response_model=List[SupportedQueryResponse],
summary=STATE.params.docs.queries.summary,
description=STATE.params.docs.queries.description,
tags=[STATE.params.docs.queries.title],
)
app.add_api_route(
path="/api/query",
endpoint=query,
methods=["POST"],
summary=STATE.params.docs.query.summary,
description=STATE.params.docs.query.description,
responses={
400: {"model": QueryError, "description": "Request Content Error"},
422: {"model": QueryError, "description": "Request Format Error"},
500: {"model": QueryError, "description": "Server Error"},
}, },
response_model=QueryResponse, on_startup=[check_redis],
tags=[STATE.params.docs.query.title], debug=STATE.settings.debug,
response_class=JSONResponse, cors_config=create_cors_config(state=STATE),
compression_config=COMPRESSION_CONFIG,
openapi_config=OPEN_API if STATE.params.docs.enable else None,
) )
app.add_api_route(
path="/ui/props/",
endpoint=ui_props,
methods=["GET", "OPTIONS"],
response_class=JSONResponse,
response_model=UIParameters,
response_model_by_alias=True,
)
if STATE.params.docs.enable:
app.add_api_route(path=STATE.params.docs.path, endpoint=docs, include_in_schema=False)
app.openapi = _custom_openapi
app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images")
app.mount("/", StaticFiles(directory=UI_DIR, html=True), name="ui")

View file

@ -1,49 +1,55 @@
"""API Error Handlers.""" """API Error Handlers."""
# Third Party # Third Party
from fastapi import Request from litestar import Request, Response
from starlette.responses import JSONResponse
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.state import use_state from hyperglass.state import use_state
__all__ = (
"default_handler",
"http_handler",
"app_handler",
"validation_handler",
)
async def default_handler(request: Request, exc: BaseException) -> JSONResponse:
def default_handler(request: Request, exc: BaseException) -> Response:
"""Handle uncaught errors.""" """Handle uncaught errors."""
state = use_state() state = use_state()
log.critical( log.critical(
"{method} {path} {detail!s}", method=request.method, path=request.url.path, detail=exc "{method} {path} {detail!s}", method=request.method, path=request.url.path, detail=exc
) )
return JSONResponse( return Response(
{"output": state.params.messages.general, "level": "danger", "keywords": []}, {"output": state.params.messages.general, "level": "danger", "keywords": []},
status_code=500, status_code=500,
) )
async def http_handler(request: Request, exc: BaseException) -> JSONResponse: def http_handler(request: Request, exc: BaseException) -> Response:
"""Handle web server errors.""" """Handle web server errors."""
log.critical( log.critical(
"{method} {path} {detail}", method=request.method, path=request.url.path, detail=exc.detail "{method} {path} {detail}", method=request.method, path=request.url.path, detail=exc.detail
) )
return JSONResponse( return Response(
{"output": exc.detail, "level": "danger", "keywords": []}, {"output": exc.detail, "level": "danger", "keywords": []},
status_code=exc.status_code, status_code=exc.status_code,
) )
async def app_handler(request: Request, exc: BaseException) -> JSONResponse: def app_handler(request: Request, exc: BaseException) -> Response:
"""Handle application errors.""" """Handle application errors."""
log.critical( log.critical(
"{method} {path} {detail}", method=request.method, path=request.url.path, detail=exc.message "{method} {path} {detail}", method=request.method, path=request.url.path, detail=exc.message
) )
return JSONResponse( return Response(
{"output": exc.message, "level": exc.level, "keywords": exc.keywords}, {"output": exc.message, "level": exc.level, "keywords": exc.keywords},
status_code=exc.status_code, status_code=exc.status_code,
) )
async def validation_handler(request: Request, exc: BaseException) -> JSONResponse: def validation_handler(request: Request, exc: BaseException) -> Response:
"""Handle Pydantic validation errors raised by FastAPI.""" """Handle Pydantic validation errors raised by FastAPI."""
error = exc.errors()[0] error = exc.errors()[0]
log.critical( log.critical(
@ -52,7 +58,7 @@ async def validation_handler(request: Request, exc: BaseException) -> JSONRespon
path=request.url.path, path=request.url.path,
detail=error["msg"], detail=error["msg"],
) )
return JSONResponse( return Response(
{"output": error["msg"], "level": "error", "keywords": error["loc"]}, {"output": error["msg"], "level": "error", "keywords": error["loc"]},
status_code=422, status_code=422,
) )

View file

@ -1,14 +1,18 @@
"""API Events.""" """API Events."""
# Standard Library
import typing as t
# Third Party
from litestar import Litestar
# Project # Project
from hyperglass.state import use_state from hyperglass.state import use_state
__all__ = ("check_redis",)
def check_redis() -> None:
async def check_redis(_: Litestar) -> t.NoReturn:
"""Ensure Redis is running before starting server.""" """Ensure Redis is running before starting server."""
cache = use_state("cache") cache = use_state("cache")
cache.check() cache.check()
on_startup = (check_redis,)
on_shutdown = ()

View file

@ -1,6 +0,0 @@
# Third Party
import httpx
request = httpx.get("%s/api/devices")
print(request.json())

View file

@ -1 +0,0 @@
curl %s/api/devices

View file

@ -1,6 +0,0 @@
# Third Party
import httpx
request = httpx.get("%s/api/queries")
print(request.json())

View file

@ -1 +0,0 @@
curl %s/api/queries

View file

@ -1,13 +0,0 @@
# Third Party
import httpx
query = {
"query_location": "router01",
"query_type": "bgp_route",
"query_vrf": "default",
"query_target": "1.1.1.0/24",
}
request = httpx.post("%s/api/query/", data=query)
print(request.json().get("output"))

View file

@ -1,7 +0,0 @@
curl -X POST %s/api/query/ -d \
'{
"query_location": "router01",
"query_type": "bgp_route",
"query_vrf": "default",
"query_target": "1.1.1.0/24"
}'

View file

@ -0,0 +1,34 @@
"""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=["*"],
)

View file

@ -3,69 +3,77 @@
# Standard Library # Standard Library
import time import time
import typing as t import typing as t
from datetime import datetime from datetime import UTC, datetime
# Third Party # Third Party
from fastapi import Depends, Request, HTTPException, BackgroundTasks from litestar import Request, Response, get, post
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from litestar.di import Provide
from litestar.background_tasks import BackgroundTask
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.state import HyperglassState, use_state from hyperglass.state import HyperglassState
from hyperglass.constants import __version__
from hyperglass.models.ui import UIParameters
from hyperglass.exceptions import HyperglassError from hyperglass.exceptions import HyperglassError
from hyperglass.models.api import Query from hyperglass.models.api import Query
from hyperglass.models.data import OutputDataModel from hyperglass.models.data import OutputDataModel
from hyperglass.util.typing import is_type from hyperglass.util.typing import is_type
from hyperglass.execution.main import execute from hyperglass.execution.main import execute
from hyperglass.models.config.params import Params from hyperglass.models.api.response import QueryResponse
from hyperglass.models.config.devices import Devices from hyperglass.models.config.params import Params, APIParams
from hyperglass.models.config.devices import Devices, APIDevice
# Local # Local
from .state import get_state, get_params, get_devices
from .tasks import send_webhook from .tasks import send_webhook
from .fake_output import fake_output from .fake_output import fake_output
__all__ = (
def get_state(attr: t.Optional[str] = None): "device",
"""Get hyperglass state as a FastAPI dependency.""" "devices",
return use_state(attr) "queries",
"info",
"query",
)
def get_params(): @get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)})
"""Get hyperglass params as FastAPI dependency.""" async def device(devices: Devices, id: str) -> APIDevice:
return use_state("params") """Retrieve a device by ID."""
return devices[id].export_api()
def get_devices(): @get("/api/devices", dependencies={"devices": Provide(get_devices)})
"""Get hyperglass devices as FastAPI dependency.""" async def devices(devices: Devices) -> t.List[APIDevice]:
return use_state("devices") """Retrieve all devices."""
return devices.export_api()
def get_ui_params(): @get("/api/queries", dependencies={"devices": Provide(get_devices)})
"""Get hyperglass ui_params as FastAPI dependency.""" async def queries(devices: Devices) -> t.List[str]:
return use_state("ui_params") """Retrieve all directive names."""
return devices.directive_names()
async def query( @get("/api/info", dependencies={"params": Provide(get_params)})
query_data: Query, async def info(params: Params) -> APIParams:
request: Request, """Retrieve looking glass parameters."""
background_tasks: BackgroundTasks, return params.export_api()
state: "HyperglassState" = Depends(get_state),
):
@post("/api/query", dependencies={"_state": Provide(get_state)})
async def query(_state: HyperglassState, request: Request, data: Query) -> QueryResponse:
"""Ingest request data pass it to the backend application to perform the query.""" """Ingest request data pass it to the backend application to perform the query."""
timestamp = datetime.utcnow() timestamp = datetime.now(UTC)
background_tasks.add_task(send_webhook, query_data, request, timestamp)
# Initialize cache # Initialize cache
cache = state.redis cache = _state.redis
# Use hashed query_data string as key for for k/v cache store so # Use hashed `data` string as key for for k/v cache store so
# each command output value is unique. # each command output value is unique.
cache_key = f"hyperglass.query.{query_data.digest()}" cache_key = f"hyperglass.query.{data.digest()}"
log.info("{!r} starting query execution", query_data) log.info("{!r} starting query execution", data)
cache_response = cache.get_map(cache_key, "output") cache_response = cache.get_map(cache_key, "output")
json_output = False json_output = False
@ -73,38 +81,38 @@ async def query(
runtime = 65535 runtime = 65535
if cache_response: if cache_response:
log.debug("{!r} cache hit (cache key {!r})", query_data, cache_key) log.debug("{!r} cache hit (cache key {!r})", data, cache_key)
# If a cached response exists, reset the expiration time. # If a cached response exists, reset the expiration time.
cache.expire(cache_key, expire_in=state.params.cache.timeout) cache.expire(cache_key, expire_in=_state.params.cache.timeout)
cached = True cached = True
runtime = 0 runtime = 0
timestamp = cache.get_map(cache_key, "timestamp") timestamp = cache.get_map(cache_key, "timestamp")
elif not cache_response: elif not cache_response:
log.debug("{!r} cache miss (cache key {!r})", query_data, cache_key) log.debug("{!r} cache miss (cache key {!r})", data, cache_key)
timestamp = query_data.timestamp timestamp = data.timestamp
starttime = time.time() starttime = time.time()
if state.params.fake_output: if _state.params.fake_output:
# Return fake, static data for development purposes, if enabled. # Return fake, static data for development purposes, if enabled.
output = await fake_output( output = await fake_output(
query_type=query_data.query_type, query_type=data.query_type,
structured=query_data.device.structured_output or False, structured=data.device.structured_output or False,
) )
else: else:
# Pass request to execution module # Pass request to execution module
output = await execute(query_data) output = await execute(data)
endtime = time.time() endtime = time.time()
elapsedtime = round(endtime - starttime, 4) elapsedtime = round(endtime - starttime, 4)
log.debug("{!r} runtime: {!s} seconds", query_data, elapsedtime) log.debug("{!r} runtime: {!s} seconds", data, elapsedtime)
if output is None: if output is None:
raise HyperglassError(message=state.params.messages.general, alert="danger") raise HyperglassError(message=_state.params.messages.general, alert="danger")
json_output = is_type(output, OutputDataModel) json_output = is_type(output, OutputDataModel)
@ -115,9 +123,9 @@ async def query(
cache.set_map_item(cache_key, "output", raw_output) cache.set_map_item(cache_key, "output", raw_output)
cache.set_map_item(cache_key, "timestamp", timestamp) cache.set_map_item(cache_key, "timestamp", timestamp)
cache.expire(cache_key, expire_in=state.params.cache.timeout) cache.expire(cache_key, expire_in=_state.params.cache.timeout)
log.debug("{!r} cached for {!s} seconds", query_data, state.params.cache.timeout) log.debug("{!r} cached for {!s} seconds", data, _state.params.cache.timeout)
runtime = int(round(elapsedtime, 0)) runtime = int(round(elapsedtime, 0))
@ -130,60 +138,27 @@ async def query(
if json_output: if json_output:
response_format = "application/json" response_format = "application/json"
log.success("{!r} execution completed", query_data) log.success("{!r} execution completed", data)
return { response = {
"output": cache_response, "output": cache_response,
"id": cache_key, "id": cache_key,
"cached": cached, "cached": cached,
"runtime": runtime, "runtime": runtime,
"timestamp": timestamp, "timestamp": timestamp,
"format": response_format, "format": response_format,
"random": query_data.random(), "random": data.random(),
"level": "success", "level": "success",
"keywords": [], "keywords": [],
} }
return Response(
async def docs(params: "Params" = Depends(get_params)): response,
"""Serve custom docs.""" background=BackgroundTask(
if params.docs.enable: send_webhook,
docs_func_map = {"swagger": get_swagger_ui_html, "redoc": get_redoc_html} params=_state.params,
docs_func = docs_func_map[params.docs.mode] data=data,
return docs_func( request=request,
openapi_url=params.docs.openapi_url, title=params.site_title + " - API Docs" timestamp=timestamp,
) ),
raise HTTPException(detail="Not found", status_code=404) )
async def router(id: str, devices: "Devices" = Depends(get_devices)):
"""Get a device's API-facing attributes."""
return devices[id].export_api()
async def routers(devices: "Devices" = Depends(get_devices)):
"""Serve list of configured routers and attributes."""
return devices.export_api()
async def queries(params: "Params" = Depends(get_params)):
"""Serve list of enabled query types."""
return params.queries.list
async def info(params: "Params" = Depends(get_params)):
"""Serve general information about this instance of hyperglass."""
return {
"name": params.site_title,
"organization": params.org_name,
"primary_asn": int(params.primary_asn),
"version": __version__,
}
async def ui_props(ui_params: "UIParameters" = Depends(get_ui_params)):
"""Serve UI configration."""
return ui_params
endpoints = [query, docs, routers, info, ui_props]

27
hyperglass/api/state.py Normal file
View file

@ -0,0 +1,27 @@
"""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")

View file

@ -1,55 +1,26 @@
"""Tasks to be executed from web API.""" """Tasks to be executed from web API."""
# Standard Library # Standard Library
from typing import Dict, Union import typing as t
from pathlib import Path
from datetime import datetime from datetime import datetime
# Third Party # Third Party
from httpx import Headers from httpx import Headers
from starlette.requests import Request from litestar import Request
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.state import use_state
from hyperglass.external import Webhook, bgptools from hyperglass.external import Webhook, bgptools
from hyperglass.models.api import Query from hyperglass.models.api import Query
__all__ = ( if t.TYPE_CHECKING:
"import_public_key", # Project
"process_headers", from hyperglass.models.config.params import Params
"send_webhook",
) __all__ = ("send_webhook",)
def import_public_key(app_path: Union[Path, str], device_name: str, keystring: str) -> bool: async def process_headers(headers: Headers) -> t.Dict[str, t.Any]:
"""Import a public key for hyperglass-agent."""
if not isinstance(app_path, Path):
app_path = Path(app_path)
cert_dir = app_path / "certs"
if not cert_dir.exists():
cert_dir.mkdir()
if not cert_dir.exists():
raise RuntimeError(f"Failed to create certs directory at {str(cert_dir)}")
filename = f"{device_name}.pem"
cert_file = cert_dir / filename
with cert_file.open("w+") as file:
file.write(str(keystring))
with cert_file.open("r") as file:
read_file = file.read().strip()
if not keystring == read_file:
raise RuntimeError("Wrote key, but written file did not match input key")
return True
async def process_headers(headers: Headers) -> Dict:
"""Filter out unwanted headers and return as a dictionary.""" """Filter out unwanted headers and return as a dictionary."""
headers = dict(headers) headers = dict(headers)
header_keys = ( header_keys = (
@ -64,12 +35,12 @@ async def process_headers(headers: Headers) -> Dict:
async def send_webhook( async def send_webhook(
query_data: Query, params: "Params",
data: Query,
request: Request, request: Request,
timestamp: datetime, timestamp: datetime,
): ) -> t.NoReturn:
"""If webhooks are enabled, get request info and send a webhook.""" """If webhooks are enabled, get request info and send a webhook."""
params = use_state("params")
try: try:
if params.logging.http is not None: if params.logging.http is not None:
headers = await process_headers(headers=request.headers) headers = await process_headers(headers=request.headers)
@ -84,10 +55,9 @@ async def send_webhook(
network_info = await bgptools.network_info(host) network_info = await bgptools.network_info(host)
async with Webhook(params.logging.http) as hook: async with Webhook(params.logging.http) as hook:
await hook.send( await hook.send(
query={ query={
**query_data.dict(), **data.dict(),
"headers": headers, "headers": headers,
"source": host, "source": host,
"network": network_info.get(host, {}), "network": network_info.get(host, {}),
@ -95,4 +65,4 @@ async def send_webhook(
} }
) )
except Exception as err: except Exception as err:
log.error("Error sending webhook to {}: {}", params.logging.http.provider, str(err)) log.error("Error sending webhook to {}: {!s}", params.logging.http.provider, err)

View file

@ -35,6 +35,14 @@ from .http_client import HttpConfiguration
ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()} 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"): class DirectiveOptions(HyperglassModel, extra="ignore"):
"""Per-device directive options.""" """Per-device directive options."""
@ -99,7 +107,7 @@ class Device(HyperglassModelWithId, extra="allow"):
return {"id": device_id, "name": display_name, "display_name": None, **values} return {"id": device_id, "name": display_name, "display_name": None, **values}
def export_api(self) -> t.Dict[str, t.Any]: def export_api(self) -> APIDevice:
"""Export API-facing device fields.""" """Export API-facing device fields."""
return { return {
"id": self.id, "id": self.id,
@ -122,6 +130,11 @@ class Device(HyperglassModelWithId, extra="allow"):
"""Get all directive IDs associated with the device.""" """Get all directive IDs associated with the device."""
return [directive.id for directive in self.directives] 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: def has_directives(self, *directive_ids: str) -> bool:
"""Determine if a directive is used on this device.""" """Determine if a directive is used on this device."""
for directive_id in directive_ids: for directive_id in directive_ids:
@ -304,7 +317,7 @@ class Devices(MultiModel, model=Device, unique_by="id"):
with_id = (Device._with_id(item) for item in items) with_id = (Device._with_id(item) for item in items)
super().__init__(*with_id) super().__init__(*with_id)
def export_api(self: "Devices") -> t.List[t.Dict[str, t.Any]]: def export_api(self: "Devices") -> t.List[APIDevice]:
"""Export API-facing device fields.""" """Export API-facing device fields."""
return [d.export_api() for d in self] return [d.export_api() for d in self]
@ -332,6 +345,10 @@ class Devices(MultiModel, model=Device, unique_by="id"):
# Convert the directive set to a tuple. # Convert the directive set to a tuple.
return {k: tuple(v) for k, v in result.items()} 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]]: def frontend(self: "Devices") -> t.List[t.Dict[str, t.Any]]:
"""Export grouped devices for UIParameters.""" """Export grouped devices for UIParameters."""
groups = {device.group for device in self} groups = {device.group for device in self}

View file

@ -48,7 +48,7 @@ class Docs(HyperglassModel):
description="Base URL used in request samples.", description="Base URL used in request samples.",
) )
path: AnyUri = Field( path: AnyUri = Field(
"/api/docs", "/docs",
title="URI", title="URI",
description="HTTP URI/path where API documentation can be accessed.", description="HTTP URI/path where API documentation can be accessed.",
) )

View file

@ -2,14 +2,15 @@
# Standard Library # Standard Library
import typing as t import typing as t
from pathlib import Path
import urllib.parse import urllib.parse
from pathlib import Path
# Third Party # Third Party
from pydantic import Field, ConfigDict, ValidationInfo, field_validator, HttpUrl from pydantic import Field, HttpUrl, ConfigDict, ValidationInfo, field_validator
# Project # Project
from hyperglass.settings import Settings from hyperglass.settings import Settings
from hyperglass.constants import __version__
# Local # Local
from .web import Web from .web import Web
@ -23,6 +24,15 @@ from .structured import Structured
Localhost = t.Literal["localhost"] Localhost = t.Literal["localhost"]
class APIParams(t.TypedDict):
"""/api/info response model."""
name: str
organization: str
primary_asn: int
version: str
class ParamsPublic(HyperglassModel): class ParamsPublic(HyperglassModel):
"""Public configuration parameters.""" """Public configuration parameters."""
@ -124,6 +134,15 @@ class Params(ParamsPublic, HyperglassModel):
"""Get all validated external common plugins as Path objects.""" """Get all validated external common plugins as Path objects."""
return tuple(Path(p) for p in self.plugins) 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]: def frontend(self) -> t.Dict[str, t.Any]:
"""Export UI-specific parameters.""" """Export UI-specific parameters."""

View file

@ -11,7 +11,6 @@ dependencies = [
"PyYAML>=6.0", "PyYAML>=6.0",
"aiofiles>=23.2.1", "aiofiles>=23.2.1",
"distro==1.8.0", "distro==1.8.0",
"fastapi>=0.110.0",
"favicons==0.2.2", "favicons==0.2.2",
"gunicorn>=21.2.0", "gunicorn>=21.2.0",
"httpx==0.24.0", "httpx==0.24.0",
@ -30,6 +29,7 @@ dependencies = [
"toml>=0.10.2", "toml>=0.10.2",
"pydantic-settings>=2.2.1", "pydantic-settings>=2.2.1",
"pydantic-extra-types>=2.6.0", "pydantic-extra-types>=2.6.0",
"litestar[standard,brotli]>=2.7.0",
] ]
readme = "README.md" readme = "README.md"
requires-python = ">= 3.11" requires-python = ">= 3.11"
@ -75,7 +75,7 @@ import_heading_stdlib = "Standard Library"
import_heading_thirdparty = "Third Party" import_heading_thirdparty = "Third Party"
include_trailing_comma = true include_trailing_comma = true
indent = ' ' indent = ' '
known_third_party = ["starlette", "fastapi", "inquirer"] known_third_party = ["litestar", "inquirer"]
length_sort = true length_sort = true
line_length = 100 line_length = 100
multi_line_output = 3 multi_line_output = 3

View file

@ -14,11 +14,16 @@ annotated-types==0.6.0
# via pydantic # via pydantic
anyio==4.3.0 anyio==4.3.0
# via httpcore # via httpcore
# via starlette # via litestar
# via watchfiles
async-timeout==4.0.3
# via redis
bandit==1.7.7 bandit==1.7.7
bcrypt==4.1.2 bcrypt==4.1.2
# via paramiko # via paramiko
black==24.2.0 black==24.2.0
brotli==1.1.0
# via litestar
certifi==2024.2.2 certifi==2024.2.2
# via httpcore # via httpcore
# via httpx # via httpx
@ -31,6 +36,8 @@ chardet==5.2.0
# via reportlab # via reportlab
click==8.1.7 click==8.1.7
# via black # via black
# via litestar
# via rich-click
# via typer # via typer
# via uvicorn # via uvicorn
colorama==0.4.6 colorama==0.4.6
@ -43,8 +50,12 @@ distlib==0.3.8
# via virtualenv # via virtualenv
distro==1.8.0 distro==1.8.0
# via hyperglass # via hyperglass
fastapi==0.110.0 editorconfig==0.12.4
# via hyperglass # via jsbeautifier
faker==24.4.0
# via polyfactory
fast-query-parsers==1.0.3
# via litestar
favicons==0.2.2 favicons==0.2.2
# via hyperglass # via hyperglass
filelock==3.13.1 filelock==3.13.1
@ -62,8 +73,11 @@ h11==0.14.0
# via uvicorn # via uvicorn
httpcore==0.17.3 httpcore==0.17.3
# via httpx # via httpx
httptools==0.6.1
# via uvicorn
httpx==0.24.0 httpx==0.24.0
# via hyperglass # via hyperglass
# via litestar
identify==2.5.35 identify==2.5.35
# via pre-commit # via pre-commit
idna==3.6 idna==3.6
@ -72,16 +86,28 @@ idna==3.6
iniconfig==2.0.0 iniconfig==2.0.0
# via pytest # via pytest
isort==5.13.2 isort==5.13.2
jinja2==3.1.3
# via litestar
jsbeautifier==1.15.1
# via litestar
litestar==2.7.0
# via hyperglass
loguru==0.7.0 loguru==0.7.0
# via hyperglass # via hyperglass
lxml==5.1.0 lxml==5.1.0
# via svglib # via svglib
markdown-it-py==3.0.0 markdown-it-py==3.0.0
# via rich # via rich
markupsafe==2.1.5
# via jinja2
mccabe==0.7.0 mccabe==0.7.0
# via flake8 # via flake8
mdurl==0.1.2 mdurl==0.1.2
# via markdown-it-py # via markdown-it-py
msgspec==0.18.6
# via litestar
multidict==6.0.5
# via litestar
mypy-extensions==1.0.0 mypy-extensions==1.0.0
# via black # via black
netmiko==4.1.2 netmiko==4.1.2
@ -112,6 +138,8 @@ platformdirs==4.2.0
# via virtualenv # via virtualenv
pluggy==1.4.0 pluggy==1.4.0
# via pytest # via pytest
polyfactory==2.15.0
# via litestar
pre-commit==3.6.2 pre-commit==3.6.2
psutil==5.9.4 psutil==5.9.4
# via hyperglass # via hyperglass
@ -125,7 +153,6 @@ pycodestyle==2.11.1
pycparser==2.21 pycparser==2.21
# via cffi # via cffi
pydantic==2.6.3 pydantic==2.6.3
# via fastapi
# via hyperglass # via hyperglass
# via pydantic-extra-types # via pydantic-extra-types
# via pydantic-settings # via pydantic-settings
@ -150,13 +177,18 @@ pytest==8.0.1
# via pytest-dependency # via pytest-dependency
pytest-asyncio==0.23.5 pytest-asyncio==0.23.5
pytest-dependency==0.6.0 pytest-dependency==0.6.0
python-dateutil==2.9.0.post0
# via faker
python-dotenv==1.0.1 python-dotenv==1.0.1
# via pydantic-settings # via pydantic-settings
# via uvicorn
pyyaml==6.0.1 pyyaml==6.0.1
# via bandit # via bandit
# via hyperglass # via hyperglass
# via litestar
# via netmiko # via netmiko
# via pre-commit # via pre-commit
# via uvicorn
redis==4.5.4 redis==4.5.4
# via hyperglass # via hyperglass
reportlab==4.1.0 reportlab==4.1.0
@ -166,6 +198,10 @@ rich==13.7.0
# via bandit # via bandit
# via favicons # via favicons
# via hyperglass # via hyperglass
# via litestar
# via rich-click
rich-click==1.7.4
# via litestar
rlpycairo==0.3.0 rlpycairo==0.3.0
# via favicons # via favicons
ruff==0.2.2 ruff==0.2.2
@ -176,14 +212,14 @@ setuptools==69.1.0
# via nodeenv # via nodeenv
# via pytest-dependency # via pytest-dependency
six==1.16.0 six==1.16.0
# via jsbeautifier
# via python-dateutil
# via textfsm # via textfsm
sniffio==1.3.0 sniffio==1.3.0
# via anyio # via anyio
# via httpcore # via httpcore
# via httpx # via httpx
stackprinter==0.2.11 stackprinter==0.2.11
starlette==0.36.3
# via fastapi
stevedore==5.1.0 stevedore==5.1.0
# via bandit # via bandit
svglib==1.5.1 svglib==1.5.1
@ -205,18 +241,27 @@ typer==0.9.0
# via favicons # via favicons
# via hyperglass # via hyperglass
typing-extensions==4.9.0 typing-extensions==4.9.0
# via fastapi # via litestar
# via polyfactory
# via pydantic # via pydantic
# via pydantic-core # via pydantic-core
# via rich-click
# via typer # via typer
uvicorn==0.21.1 uvicorn==0.21.1
# via hyperglass # via hyperglass
uvloop==0.17.0 # via litestar
uvloop==0.19.0
# via hyperglass # via hyperglass
# via litestar
# via uvicorn
virtualenv==20.25.0 virtualenv==20.25.0
# via pre-commit # via pre-commit
watchfiles==0.21.0
# via uvicorn
webencodings==0.5.1 webencodings==0.5.1
# via cssselect2 # via cssselect2
# via tinycss2 # via tinycss2
websockets==12.0
# via uvicorn
xmltodict==0.13.0 xmltodict==0.13.0
# via hyperglass # via hyperglass

View file

@ -14,9 +14,14 @@ annotated-types==0.6.0
# via pydantic # via pydantic
anyio==4.3.0 anyio==4.3.0
# via httpcore # via httpcore
# via starlette # via litestar
# via watchfiles
async-timeout==4.0.3
# via redis
bcrypt==4.1.2 bcrypt==4.1.2
# via paramiko # via paramiko
brotli==1.1.0
# via litestar
certifi==2024.2.2 certifi==2024.2.2
# via httpcore # via httpcore
# via httpx # via httpx
@ -26,6 +31,8 @@ cffi==1.16.0
chardet==5.2.0 chardet==5.2.0
# via reportlab # via reportlab
click==8.1.7 click==8.1.7
# via litestar
# via rich-click
# via typer # via typer
# via uvicorn # via uvicorn
cryptography==42.0.3 cryptography==42.0.3
@ -34,8 +41,12 @@ cssselect2==0.7.0
# via svglib # via svglib
distro==1.8.0 distro==1.8.0
# via hyperglass # via hyperglass
fastapi==0.110.0 editorconfig==0.12.4
# via hyperglass # via jsbeautifier
faker==24.4.0
# via polyfactory
fast-query-parsers==1.0.3
# via litestar
favicons==0.2.2 favicons==0.2.2
# via hyperglass # via hyperglass
freetype-py==2.4.0 freetype-py==2.4.0
@ -49,19 +60,34 @@ h11==0.14.0
# via uvicorn # via uvicorn
httpcore==0.17.3 httpcore==0.17.3
# via httpx # via httpx
httptools==0.6.1
# via uvicorn
httpx==0.24.0 httpx==0.24.0
# via hyperglass # via hyperglass
# via litestar
idna==3.6 idna==3.6
# via anyio # via anyio
# via httpx # via httpx
jinja2==3.1.3
# via litestar
jsbeautifier==1.15.1
# via litestar
litestar==2.7.0
# via hyperglass
loguru==0.7.0 loguru==0.7.0
# via hyperglass # via hyperglass
lxml==5.1.0 lxml==5.1.0
# via svglib # via svglib
markdown-it-py==3.0.0 markdown-it-py==3.0.0
# via rich # via rich
markupsafe==2.1.5
# via jinja2
mdurl==0.1.2 mdurl==0.1.2
# via markdown-it-py # via markdown-it-py
msgspec==0.18.6
# via litestar
multidict==6.0.5
# via litestar
netmiko==4.1.2 netmiko==4.1.2
# via hyperglass # via hyperglass
ntc-templates==4.3.0 ntc-templates==4.3.0
@ -76,6 +102,8 @@ pillow==10.2.0
# via favicons # via favicons
# via hyperglass # via hyperglass
# via reportlab # via reportlab
polyfactory==2.15.0
# via litestar
psutil==5.9.4 psutil==5.9.4
# via hyperglass # via hyperglass
py-cpuinfo==9.0.0 py-cpuinfo==9.0.0
@ -85,7 +113,6 @@ pycairo==1.26.0
pycparser==2.21 pycparser==2.21
# via cffi # via cffi
pydantic==2.6.3 pydantic==2.6.3
# via fastapi
# via hyperglass # via hyperglass
# via pydantic-extra-types # via pydantic-extra-types
# via pydantic-settings # via pydantic-settings
@ -103,11 +130,16 @@ pynacl==1.5.0
# via paramiko # via paramiko
pyserial==3.5 pyserial==3.5
# via netmiko # via netmiko
python-dateutil==2.9.0.post0
# via faker
python-dotenv==1.0.1 python-dotenv==1.0.1
# via pydantic-settings # via pydantic-settings
# via uvicorn
pyyaml==6.0.1 pyyaml==6.0.1
# via hyperglass # via hyperglass
# via litestar
# via netmiko # via netmiko
# via uvicorn
redis==4.5.4 redis==4.5.4
# via hyperglass # via hyperglass
reportlab==4.1.0 reportlab==4.1.0
@ -116,6 +148,10 @@ reportlab==4.1.0
rich==13.7.0 rich==13.7.0
# via favicons # via favicons
# via hyperglass # via hyperglass
# via litestar
# via rich-click
rich-click==1.7.4
# via litestar
rlpycairo==0.3.0 rlpycairo==0.3.0
# via favicons # via favicons
scp==0.14.5 scp==0.14.5
@ -123,13 +159,13 @@ scp==0.14.5
setuptools==69.1.0 setuptools==69.1.0
# via netmiko # via netmiko
six==1.16.0 six==1.16.0
# via jsbeautifier
# via python-dateutil
# via textfsm # via textfsm
sniffio==1.3.0 sniffio==1.3.0
# via anyio # via anyio
# via httpcore # via httpcore
# via httpx # via httpx
starlette==0.36.3
# via fastapi
svglib==1.5.1 svglib==1.5.1
# via favicons # via favicons
tenacity==8.2.3 tenacity==8.2.3
@ -146,16 +182,25 @@ typer==0.9.0
# via favicons # via favicons
# via hyperglass # via hyperglass
typing-extensions==4.9.0 typing-extensions==4.9.0
# via fastapi # via litestar
# via polyfactory
# via pydantic # via pydantic
# via pydantic-core # via pydantic-core
# via rich-click
# via typer # via typer
uvicorn==0.21.1 uvicorn==0.21.1
# via hyperglass # via hyperglass
uvloop==0.17.0 # via litestar
uvloop==0.19.0
# via hyperglass # via hyperglass
# via litestar
# via uvicorn
watchfiles==0.21.0
# via uvicorn
webencodings==0.5.1 webencodings==0.5.1
# via cssselect2 # via cssselect2
# via tinycss2 # via tinycss2
websockets==12.0
# via uvicorn
xmltodict==0.13.0 xmltodict==0.13.0
# via hyperglass # via hyperglass