Implement global state

This commit is contained in:
thatmattlove 2021-09-15 00:57:45 -07:00
parent b002c9d520
commit a2ee4b50fa
29 changed files with 702 additions and 345 deletions

View file

@ -18,7 +18,7 @@ per-file-ignores=
hyperglass/models/*/__init__.py:F401 hyperglass/models/*/__init__.py:F401
# Disable assertion and docstring checks on tests. # Disable assertion and docstring checks on tests.
hyperglass/**/test_*.py:S101,D103 hyperglass/**/test_*.py:S101,D103
ignore=W503,C0330,R504,D202,S403,S301,S404,E731 ignore=W503,C0330,R504,D202,S403,S301,S404,E731,D402
select=B, BLK, C, D, E, F, I, II, N, P, PIE, S, R, W select=B, BLK, C, D, E, F, I, II, N, P, PIE, S, R, W
disable-noqa=False disable-noqa=False
hang-closing=False hang-closing=False

View file

@ -22,8 +22,9 @@ from hyperglass.constants import __version__
from hyperglass.models.ui import UIParameters from hyperglass.models.ui import UIParameters
from hyperglass.api.events import on_startup, on_shutdown from hyperglass.api.events import on_startup, on_shutdown
from hyperglass.api.routes import docs, info, query, router, queries, routers, ui_props from hyperglass.api.routes import docs, info, query, router, queries, routers, ui_props
from hyperglass.state import use_state
from hyperglass.exceptions import HyperglassError from hyperglass.exceptions import HyperglassError
from hyperglass.configuration import URL_DEV, STATIC_PATH, params from hyperglass.configuration import URL_DEV, STATIC_PATH
from hyperglass.api.error_handlers import ( from hyperglass.api.error_handlers import (
app_handler, app_handler,
http_handler, http_handler,
@ -39,6 +40,8 @@ from hyperglass.models.api.response import (
SupportedQueryResponse, SupportedQueryResponse,
) )
STATE = use_state()
WORKING_DIR = Path(__file__).parent WORKING_DIR = Path(__file__).parent
EXAMPLES_DIR = WORKING_DIR / "examples" EXAMPLES_DIR = WORKING_DIR / "examples"
@ -54,18 +57,18 @@ EXAMPLE_QUERIES_CURL = EXAMPLES_DIR / "queries.sh"
EXAMPLE_QUERY_CURL = EXAMPLES_DIR / "query.sh" EXAMPLE_QUERY_CURL = EXAMPLES_DIR / "query.sh"
ASGI_PARAMS = { ASGI_PARAMS = {
"host": str(params.listen_address), "host": str(STATE.settings.host),
"port": params.listen_port, "port": STATE.settings.port,
"debug": params.debug, "debug": STATE.settings.debug,
"workers": cpu_count(2), "workers": cpu_count(2),
} }
DOCS_PARAMS = {} DOCS_PARAMS = {}
if params.docs.enable: if STATE.params.docs.enable:
DOCS_PARAMS.update({"openapi_url": params.docs.openapi_uri}) DOCS_PARAMS.update({"openapi_url": STATE.params.docs.openapi_uri})
if params.docs.mode == "redoc": if STATE.params.docs.mode == "redoc":
DOCS_PARAMS.update({"docs_url": None, "redoc_url": params.docs.uri}) DOCS_PARAMS.update({"docs_url": None, "redoc_url": STATE.params.docs.uri})
elif params.docs.mode == "swagger": elif STATE.params.docs.mode == "swagger":
DOCS_PARAMS.update({"docs_url": params.docs.uri, "redoc_url": None}) DOCS_PARAMS.update({"docs_url": STATE.params.docs.uri, "redoc_url": None})
for directory in (UI_DIR, IMAGES_DIR): for directory in (UI_DIR, IMAGES_DIR):
if not directory.exists(): if not directory.exists():
@ -74,9 +77,9 @@ for directory in (UI_DIR, IMAGES_DIR):
# Main App Definition # Main App Definition
app = FastAPI( app = FastAPI(
debug=params.debug, debug=STATE.settings.debug,
title=params.site_title, title=STATE.params.site_title,
description=params.site_description, description=STATE.params.site_description,
version=__version__, version=__version__,
default_response_class=JSONResponse, default_response_class=JSONResponse,
**DOCS_PARAMS, **DOCS_PARAMS,
@ -108,12 +111,12 @@ app.add_exception_handler(Exception, default_handler)
def _custom_openapi(): def _custom_openapi():
"""Generate custom OpenAPI config.""" """Generate custom OpenAPI config."""
openapi_schema = get_openapi( openapi_schema = get_openapi(
title=params.docs.title.format(site_title=params.site_title), title=STATE.params.docs.title.format(site_title=STATE.params.site_title),
version=__version__, version=__version__,
description=params.docs.description, description=STATE.params.docs.description,
routes=app.routes, routes=app.routes,
) )
openapi_schema["info"]["x-logo"] = {"url": "/images/light" + params.web.logo.light.suffix} openapi_schema["info"]["x-logo"] = {"url": "/images/light" + STATE.params.web.logo.light.suffix}
query_samples = [] query_samples = []
queries_samples = [] queries_samples = []
@ -121,26 +124,36 @@ def _custom_openapi():
with EXAMPLE_QUERY_CURL.open("r") as e: with EXAMPLE_QUERY_CURL.open("r") as e:
example = e.read() example = e.read()
query_samples.append({"lang": "cURL", "source": example % str(params.docs.base_url)}) query_samples.append({"lang": "cURL", "source": example % str(STATE.params.docs.base_url)})
with EXAMPLE_QUERY_PY.open("r") as e: with EXAMPLE_QUERY_PY.open("r") as e:
example = e.read() example = e.read()
query_samples.append({"lang": "Python", "source": example % str(params.docs.base_url)}) query_samples.append(
{"lang": "Python", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_DEVICES_CURL.open("r") as e: with EXAMPLE_DEVICES_CURL.open("r") as e:
example = e.read() example = e.read()
queries_samples.append({"lang": "cURL", "source": example % str(params.docs.base_url)}) queries_samples.append(
{"lang": "cURL", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_DEVICES_PY.open("r") as e: with EXAMPLE_DEVICES_PY.open("r") as e:
example = e.read() example = e.read()
queries_samples.append({"lang": "Python", "source": example % str(params.docs.base_url)}) queries_samples.append(
{"lang": "Python", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_QUERIES_CURL.open("r") as e: with EXAMPLE_QUERIES_CURL.open("r") as e:
example = e.read() example = e.read()
devices_samples.append({"lang": "cURL", "source": example % str(params.docs.base_url)}) devices_samples.append(
{"lang": "cURL", "source": example % str(STATE.params.docs.base_url)}
)
with EXAMPLE_QUERIES_PY.open("r") as e: with EXAMPLE_QUERIES_PY.open("r") as e:
example = e.read() example = e.read()
devices_samples.append({"lang": "Python", "source": example % str(params.docs.base_url)}) 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/query/"]["post"]["x-code-samples"] = query_samples
openapi_schema["paths"]["/api/devices"]["get"]["x-code-samples"] = devices_samples openapi_schema["paths"]["/api/devices"]["get"]["x-code-samples"] = devices_samples
@ -150,8 +163,8 @@ def _custom_openapi():
return app.openapi_schema return app.openapi_schema
CORS_ORIGINS = params.cors_origins.copy() CORS_ORIGINS = STATE.params.cors_origins.copy()
if params.developer_mode: if STATE.settings.dev_mode:
CORS_ORIGINS = [*CORS_ORIGINS, URL_DEV, "http://localhost:3000"] CORS_ORIGINS = [*CORS_ORIGINS, URL_DEV, "http://localhost:3000"]
# CORS Configuration # CORS Configuration
@ -171,9 +184,9 @@ app.add_api_route(
methods=["GET"], methods=["GET"],
response_model=InfoResponse, response_model=InfoResponse,
response_class=JSONResponse, response_class=JSONResponse,
summary=params.docs.info.summary, summary=STATE.params.docs.info.summary,
description=params.docs.info.description, description=STATE.params.docs.info.description,
tags=[params.docs.info.title], tags=[STATE.params.docs.info.title],
) )
app.add_api_route( app.add_api_route(
@ -182,9 +195,9 @@ app.add_api_route(
methods=["GET"], methods=["GET"],
response_model=List[RoutersResponse], response_model=List[RoutersResponse],
response_class=JSONResponse, response_class=JSONResponse,
summary=params.docs.devices.summary, summary=STATE.params.docs.devices.summary,
description=params.docs.devices.description, description=STATE.params.docs.devices.description,
tags=[params.docs.devices.title], tags=[STATE.params.docs.devices.title],
) )
app.add_api_route( app.add_api_route(
@ -193,9 +206,9 @@ app.add_api_route(
methods=["GET"], methods=["GET"],
response_model=RoutersResponse, response_model=RoutersResponse,
response_class=JSONResponse, response_class=JSONResponse,
summary=params.docs.devices.summary, summary=STATE.params.docs.devices.summary,
description=params.docs.devices.description, description=STATE.params.docs.devices.description,
tags=[params.docs.devices.title], tags=[STATE.params.docs.devices.title],
) )
app.add_api_route( app.add_api_route(
@ -204,24 +217,24 @@ app.add_api_route(
methods=["GET"], methods=["GET"],
response_class=JSONResponse, response_class=JSONResponse,
response_model=List[SupportedQueryResponse], response_model=List[SupportedQueryResponse],
summary=params.docs.queries.summary, summary=STATE.params.docs.queries.summary,
description=params.docs.queries.description, description=STATE.params.docs.queries.description,
tags=[params.docs.queries.title], tags=[STATE.params.docs.queries.title],
) )
app.add_api_route( app.add_api_route(
path="/api/query/", path="/api/query/",
endpoint=query, endpoint=query,
methods=["POST"], methods=["POST"],
summary=params.docs.query.summary, summary=STATE.params.docs.query.summary,
description=params.docs.query.description, description=STATE.params.docs.query.description,
responses={ responses={
400: {"model": QueryError, "description": "Request Content Error"}, 400: {"model": QueryError, "description": "Request Content Error"},
422: {"model": QueryError, "description": "Request Format Error"}, 422: {"model": QueryError, "description": "Request Format Error"},
500: {"model": QueryError, "description": "Server Error"}, 500: {"model": QueryError, "description": "Server Error"},
}, },
response_model=QueryResponse, response_model=QueryResponse,
tags=[params.docs.query.title], tags=[STATE.params.docs.query.title],
response_class=JSONResponse, response_class=JSONResponse,
) )
@ -235,8 +248,8 @@ app.add_api_route(
) )
if params.docs.enable: if STATE.params.docs.enable:
app.add_api_route(path=params.docs.uri, endpoint=docs, include_in_schema=False) app.add_api_route(path=STATE.params.docs.uri, endpoint=docs, include_in_schema=False)
app.openapi = _custom_openapi app.openapi = _custom_openapi
log.debug("API Docs config: {}", app.openapi()) log.debug("API Docs config: {}", app.openapi())

View file

@ -4,18 +4,21 @@
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
# Project # Project
from hyperglass.configuration import params from hyperglass.state import use_state
async def default_handler(request, exc): async def default_handler(request, exc):
"""Handle uncaught errors.""" """Handle uncaught errors."""
state = use_state()
return JSONResponse( return JSONResponse(
{"output": params.messages.general, "level": "danger", "keywords": []}, status_code=500, {"output": state.params.messages.general, "level": "danger", "keywords": []},
status_code=500,
) )
async def http_handler(request, exc): async def http_handler(request, exc):
"""Handle web server errors.""" """Handle web server errors."""
return JSONResponse( return JSONResponse(
{"output": exc.detail, "level": "danger", "keywords": []}, status_code=exc.status_code, {"output": exc.detail, "level": "danger", "keywords": []}, status_code=exc.status_code,
) )

View file

@ -1,15 +1,13 @@
"""API Events.""" """API Events."""
# Project # Project
from hyperglass.cache import AsyncCache from hyperglass.state import use_state
from hyperglass.configuration import REDIS_CONFIG, params
async def check_redis() -> bool: def check_redis() -> bool:
"""Ensure Redis is running before starting server.""" """Ensure Redis is running before starting server."""
cache = AsyncCache(db=params.cache.database, **REDIS_CONFIG) state = use_state()
await cache.test() return state._redis.ping()
return True
on_startup = (check_redis,) on_startup = (check_redis,)

View file

@ -1,9 +1,9 @@
"""API Routes.""" """API Routes."""
# Standard Library # Standard Library
import os
import json import json
import time import time
import typing as t
from datetime import datetime from datetime import datetime
# Third Party # Third Party
@ -13,25 +13,28 @@ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.cache import AsyncCache from hyperglass.state import use_state
from hyperglass.external import Webhook, bgptools from hyperglass.external import Webhook, bgptools
from hyperglass.api.tasks import process_headers from hyperglass.api.tasks import process_headers
from hyperglass.constants import __version__ from hyperglass.constants import __version__
from hyperglass.exceptions import HyperglassError from hyperglass.exceptions import HyperglassError
from hyperglass.models.api import Query
from hyperglass.configuration import REDIS_CONFIG, params, devices, ui_params
from hyperglass.execution.main import execute from hyperglass.execution.main import execute
# Local # Local
from .fake_output import fake_output from .fake_output import fake_output
APP_PATH = os.environ["hyperglass_directory"] if t.TYPE_CHECKING:
# Project
from hyperglass.models.api import Query
async def send_webhook(query_data: Query, request: Request, timestamp: datetime): STATE = use_state()
async def send_webhook(query_data: "Query", request: Request, timestamp: datetime):
"""If webhooks are enabled, get request info and send a webhook.""" """If webhooks are enabled, get request info and send a webhook."""
try: try:
if params.logging.http is not None: if STATE.params.logging.http is not None:
headers = await process_headers(headers=request.headers) headers = await process_headers(headers=request.headers)
if headers.get("x-real-ip") is not None: if headers.get("x-real-ip") is not None:
@ -43,7 +46,7 @@ async def send_webhook(query_data: Query, request: Request, timestamp: datetime)
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(STATE.params.logging.http) as hook:
await hook.send( await hook.send(
query={ query={
@ -55,30 +58,30 @@ async def send_webhook(query_data: Query, request: Request, timestamp: datetime)
} }
) )
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 {}: {}", STATE.params.logging.http.provider, str(err))
async def query(query_data: Query, request: Request, background_tasks: BackgroundTasks): async def query(query_data: "Query", request: Request, background_tasks: BackgroundTasks):
"""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.utcnow()
background_tasks.add_task(send_webhook, query_data, request, timestamp) background_tasks.add_task(send_webhook, query_data, request, timestamp)
# Initialize cache # Initialize cache
cache = AsyncCache(db=params.cache.database, **REDIS_CONFIG) cache = STATE.redis
log.debug("Initialized cache {}", repr(cache)) log.debug("Initialized cache {}", repr(cache))
# Use hashed query_data string as key for for k/v cache store so # Use hashed query_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 = query_data.digest() cache_key = f"hyperglass.query.{query_data.digest()}"
# Define cache entry expiry time # Define cache entry expiry time
cache_timeout = params.cache.timeout cache_timeout = STATE.params.cache.timeout
log.debug("Cache Timeout: {}", cache_timeout) log.debug("Cache Timeout: {}", cache_timeout)
log.info("Starting query execution for query {}", query_data.summary) log.info("Starting query execution for query {}", query_data.summary)
cache_response = await cache.get_dict(cache_key, "output") cache_response = cache.get_dict(cache_key, "output")
json_output = False json_output = False
@ -95,11 +98,11 @@ async def query(query_data: Query, request: Request, background_tasks: Backgroun
log.debug("Query {} exists in cache", cache_key) log.debug("Query {} exists in cache", cache_key)
# If a cached response exists, reset the expiration time. # If a cached response exists, reset the expiration time.
await cache.expire(cache_key, seconds=cache_timeout) cache.expire(cache_key, seconds=cache_timeout)
cached = True cached = True
runtime = 0 runtime = 0
timestamp = await cache.get_dict(cache_key, "timestamp") timestamp = cache.get_dict(cache_key, "timestamp")
elif not cache_response: elif not cache_response:
log.debug("No existing cache entry for query {}", cache_key) log.debug("No existing cache entry for query {}", cache_key)
@ -109,7 +112,7 @@ async def query(query_data: Query, request: Request, background_tasks: Backgroun
starttime = time.time() starttime = time.time()
if 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.
cache_output = await fake_output(json_output) cache_output = await fake_output(json_output)
else: else:
@ -121,23 +124,23 @@ async def query(query_data: Query, request: Request, background_tasks: Backgroun
log.debug("Query {} took {} seconds to run.", cache_key, elapsedtime) log.debug("Query {} took {} seconds to run.", cache_key, elapsedtime)
if cache_output is None: if cache_output is None:
raise HyperglassError(message=params.messages.general, alert="danger") raise HyperglassError(message=STATE.params.messages.general, alert="danger")
# Create a cache entry # Create a cache entry
if json_output: if json_output:
raw_output = json.dumps(cache_output) raw_output = json.dumps(cache_output)
else: else:
raw_output = str(cache_output) raw_output = str(cache_output)
await cache.set_dict(cache_key, "output", raw_output) cache.set_dict(cache_key, "output", raw_output)
await cache.set_dict(cache_key, "timestamp", timestamp) cache.set_dict(cache_key, "timestamp", timestamp)
await cache.expire(cache_key, seconds=cache_timeout) cache.expire(cache_key, seconds=cache_timeout)
log.debug("Added cache entry for query: {}", cache_key) log.debug("Added cache entry for query: {}", cache_key)
runtime = int(round(elapsedtime, 0)) runtime = int(round(elapsedtime, 0))
# If it does, return the cached entry # If it does, return the cached entry
cache_response = await cache.get_dict(cache_key, "output") cache_response = cache.get_dict(cache_key, "output")
response_format = "text/plain" response_format = "text/plain"
if json_output: if json_output:
@ -161,11 +164,11 @@ async def query(query_data: Query, request: Request, background_tasks: Backgroun
async def docs(): async def docs():
"""Serve custom docs.""" """Serve custom docs."""
if params.docs.enable: if STATE.params.docs.enable:
docs_func_map = {"swagger": get_swagger_ui_html, "redoc": get_redoc_html} docs_func_map = {"swagger": get_swagger_ui_html, "redoc": get_redoc_html}
docs_func = docs_func_map[params.docs.mode] docs_func = docs_func_map[STATE.params.docs.mode]
return docs_func( return docs_func(
openapi_url=params.docs.openapi_url, title=params.site_title + " - API Docs" openapi_url=STATE.params.docs.openapi_url, title=STATE.params.site_title + " - API Docs"
) )
else: else:
raise HTTPException(detail="Not found", status_code=404) raise HTTPException(detail="Not found", status_code=404)
@ -173,32 +176,32 @@ async def docs():
async def router(id: str): async def router(id: str):
"""Get a device's API-facing attributes.""" """Get a device's API-facing attributes."""
return devices[id].export_api() return STATE.devices[id].export_api()
async def routers(): async def routers():
"""Serve list of configured routers and attributes.""" """Serve list of configured routers and attributes."""
return devices.export_api() return STATE.devices.export_api()
async def queries(): async def queries():
"""Serve list of enabled query types.""" """Serve list of enabled query types."""
return params.queries.list return STATE.params.queries.list
async def info(): async def info():
"""Serve general information about this instance of hyperglass.""" """Serve general information about this instance of hyperglass."""
return { return {
"name": params.site_title, "name": STATE.params.site_title,
"organization": params.org_name, "organization": STATE.params.org_name,
"primary_asn": int(params.primary_asn), "primary_asn": int(STATE.params.primary_asn),
"version": __version__, "version": __version__,
} }
async def ui_props(): async def ui_props():
"""Serve UI configration.""" """Serve UI configration."""
return ui_params return STATE.ui_params
endpoints = [query, docs, routers, info, ui_props] endpoints = [query, docs, routers, info, ui_props]

View file

@ -4,25 +4,49 @@
import json import json
import time import time
import pickle import pickle
import typing as t
import asyncio import asyncio
from typing import Any, Dict
# Third Party # Third Party
from aredis import StrictRedis as AsyncRedis # type: ignore from aredis import StrictRedis as AsyncRedis # type: ignore
from aredis.pubsub import PubSub as AsyncPubSub # type: ignore from pydantic import SecretStr
from aredis.exceptions import RedisError # type: ignore from aredis.exceptions import RedisError # type: ignore
# Project # Project
from hyperglass.cache.base import BaseCache from hyperglass.cache.base import BaseCache
from hyperglass.exceptions.private import DependencyError from hyperglass.exceptions.private import DependencyError
if t.TYPE_CHECKING:
# Third Party
from aredis.pubsub import PubSub as AsyncPubSub # type: ignore
# Project
from hyperglass.models.config.params import Params
from hyperglass.models.config.devices import Devices
class AsyncCache(BaseCache): class AsyncCache(BaseCache):
"""Asynchronous Redis cache handler.""" """Asynchronous Redis cache handler."""
def __init__(self, *args, **kwargs): def __init__(
self,
*,
db: int,
host: str = "localhost",
port: int = 6379,
password: t.Optional[SecretStr] = None,
decode_responses: bool = False,
**kwargs: t.Any,
):
"""Initialize Redis connection.""" """Initialize Redis connection."""
super().__init__(*args, **kwargs) super().__init__(
db=db,
host=host,
port=port,
password=password,
decode_responses=decode_responses,
**kwargs,
)
password = self.password password = self.password
if password is not None: if password is not None:
@ -62,7 +86,7 @@ class AsyncCache(BaseCache):
e=err_msg, e=err_msg,
) )
async def get(self, *args: str) -> Any: async def get(self, *args: str) -> t.Any:
"""Get item(s) from cache.""" """Get item(s) from cache."""
if len(args) == 1: if len(args) == 1:
raw = await self.instance.get(args[0]) raw = await self.instance.get(args[0])
@ -70,7 +94,7 @@ class AsyncCache(BaseCache):
raw = await self.instance.mget(args) raw = await self.instance.mget(args)
return self.parse_types(raw) return self.parse_types(raw)
async def get_dict(self, key: str, field: str = "") -> Any: async def get_dict(self, key: str, field: str = "") -> t.Any:
"""Get hash map (dict) item(s).""" """Get hash map (dict) item(s)."""
if not field: if not field:
raw = await self.instance.hgetall(key) raw = await self.instance.hgetall(key)
@ -87,7 +111,7 @@ class AsyncCache(BaseCache):
"""Set hash map (dict) values.""" """Set hash map (dict) values."""
success = False success = False
if isinstance(value, Dict): if isinstance(value, t.Dict):
value = json.dumps(value) value = json.dumps(value)
else: else:
value = str(value) value = str(value)
@ -99,7 +123,7 @@ class AsyncCache(BaseCache):
return success return success
async def wait(self, pubsub: AsyncPubSub, timeout: int = 30, **kwargs) -> Any: async def wait(self, pubsub: "AsyncPubSub", timeout: int = 30, **kwargs) -> t.Any:
"""Wait for pub/sub messages & return posted message.""" """Wait for pub/sub messages & return posted message."""
now = time.time() now = time.time()
timeout = now + timeout timeout = now + timeout
@ -117,7 +141,7 @@ class AsyncCache(BaseCache):
return None return None
async def pubsub(self) -> AsyncPubSub: async def pubsub(self) -> "AsyncPubSub":
"""Provide an aredis.pubsub.Pubsub instance.""" """Provide an aredis.pubsub.Pubsub instance."""
return self.instance.pubsub() return self.instance.pubsub()
@ -139,8 +163,20 @@ class AsyncCache(BaseCache):
for key in keys: for key in keys:
await self.instance.expire(key, seconds) await self.instance.expire(key, seconds)
async def get_config(self) -> Dict: async def get_params(self: "AsyncCache") -> "Params":
"""Get picked config object from cache.""" """Get Params object from the cache."""
params = await self.instance.get(self.CONFIG_KEY)
return pickle.loads(params)
pickled = await self.instance.get("HYPERGLASS_CONFIG") async def get_devices(self: "AsyncCache") -> "Devices":
return pickle.loads(pickled) """Get Devices object from the cache."""
devices = await self.instance.get(self.DEVICES_KEY)
return pickle.loads(devices)
async def set_config(self: "AsyncCache", config: "Params") -> None:
"""Add a params instance to the cache."""
await self.instance.set(self.CONFIG_KEY, pickle.dumps(config))
async def set_devices(self: "AsyncCache", devices: "Devices") -> None:
"""Add a devices instance to the cache."""
await self.instance.set(self.DEVICES_KEY, pickle.dumps(devices))

View file

@ -3,7 +3,7 @@
# Standard Library # Standard Library
import re import re
import json import json
from typing import Any, Optional import typing as t
# Third Party # Third Party
from pydantic import SecretStr from pydantic import SecretStr
@ -12,30 +12,35 @@ from pydantic import SecretStr
class BaseCache: class BaseCache:
"""Redis cache handler.""" """Redis cache handler."""
CONFIG_KEY: str = "hyperglass.config"
DEVICES_KEY: str = "hyperglass.devices"
def __init__( def __init__(
self, self,
*,
db: int, db: int,
host: str = "localhost", host: str = "localhost",
port: int = 6379, port: int = 6379,
password: Optional[SecretStr] = None, password: t.Optional[SecretStr] = None,
decode_responses: bool = True, decode_responses: bool = False,
**kwargs: Any, **kwargs: t.Any,
) -> None: ) -> None:
"""Initialize Redis connection.""" """Initialize Redis connection."""
self.db: int = db self.db = db
self.host: str = str(host) self.host = str(host)
self.port: int = port self.port = port
self.password: Optional[SecretStr] = password self.password = password
self.decode_responses: bool = decode_responses self.decode_responses = decode_responses
self.redis_args: dict = kwargs self.redis_args = kwargs
def __repr__(self) -> str: def __repr__(self) -> str:
"""Represent class state.""" """Represent class state."""
return "HyperglassCache(db={}, host={}, port={}, password={})".format(
return "HyperglassCache(db={!s}, host={}, port={!s}, password={})".format(
self.db, self.host, self.port, self.password self.db, self.host, self.port, self.password
) )
def parse_types(self, value: str) -> Any: def parse_types(self, value: str) -> t.Any:
"""Parse a string to standard python types.""" """Parse a string to standard python types."""
def parse_string(str_value: str): def parse_string(str_value: str):
@ -56,11 +61,11 @@ class BaseCache:
value = parse_string(value) value = parse_string(value)
elif isinstance(value, bytes): elif isinstance(value, bytes):
value = parse_string(value.decode("utf-8")) value = parse_string(value.decode("utf-8"))
elif isinstance(value, list): elif isinstance(value, t.List):
value = [parse_string(i) for i in value] value = [parse_string(i) for i in value]
elif isinstance(value, tuple): elif isinstance(value, t.Tuple):
value = tuple(parse_string(i) for i in value) value = tuple(parse_string(i) for i in value)
elif isinstance(value, dict): elif isinstance(value, t.Dict):
value = {k: self.parse_types(v) for k, v in value.items()} value = {k: self.parse_types(v) for k, v in value.items()}
return value return value

View file

@ -4,24 +4,48 @@
import json import json
import time import time
import pickle import pickle
from typing import Any, Dict import typing as t
# Third Party # Third Party
from redis import Redis as SyncRedis from redis import Redis as SyncRedis
from redis.client import PubSub as SyncPubsSub from pydantic import SecretStr
from redis.exceptions import RedisError from redis.exceptions import RedisError
# Project # Project
from hyperglass.cache.base import BaseCache from hyperglass.cache.base import BaseCache
from hyperglass.exceptions.private import DependencyError from hyperglass.exceptions.private import DependencyError
if t.TYPE_CHECKING:
# Third Party
from redis.client import PubSub as SyncPubsSub
# Project
from hyperglass.models.config.params import Params
from hyperglass.models.config.devices import Devices
class SyncCache(BaseCache): class SyncCache(BaseCache):
"""Synchronous Redis cache handler.""" """Synchronous Redis cache handler."""
def __init__(self, *args, **kwargs): def __init__(
self,
*,
db: int,
host: str = "localhost",
port: int = 6379,
password: t.Optional[SecretStr] = None,
decode_responses: bool = False,
**kwargs: t.Any,
):
"""Initialize Redis connection.""" """Initialize Redis connection."""
super().__init__(*args, **kwargs) super().__init__(
db=db,
host=host,
port=port,
password=password,
decode_responses=decode_responses,
**kwargs,
)
password = self.password password = self.password
if password is not None: if password is not None:
@ -60,15 +84,25 @@ class SyncCache(BaseCache):
e=err_msg, e=err_msg,
) )
def get(self, *args: str) -> Any: def get(self, *args: str, decode: bool = True) -> t.Any:
"""Get item(s) from cache.""" """Get item(s) from cache."""
if len(args) == 1: if len(args) == 1:
raw = self.instance.get(args[0]) raw = self.instance.get(args[0])
else: else:
raw = self.instance.mget(args) raw = self.instance.mget(args)
if decode and isinstance(raw, bytes):
raw = raw.decode()
return self.parse_types(raw) return self.parse_types(raw)
def get_dict(self, key: str, field: str = "") -> Any: GetObj = t.TypeVar("GetObj")
def get_object(self, name: str, _type: t.Type[GetObj] = t.Any) -> GetObj:
raw = self.instance.get(name)
obj: _type = pickle.loads(raw)
return obj
def get_dict(self, key: str, field: str = "", *, decode: bool = True) -> t.Any:
"""Get hash map (dict) item(s).""" """Get hash map (dict) item(s)."""
if not field: if not field:
raw = self.instance.hgetall(key) raw = self.instance.hgetall(key)
@ -85,7 +119,7 @@ class SyncCache(BaseCache):
"""Set hash map (dict) values.""" """Set hash map (dict) values."""
success = False success = False
if isinstance(value, Dict): if isinstance(value, t.Dict):
value = json.dumps(value) value = json.dumps(value)
else: else:
value = str(value) value = str(value)
@ -97,7 +131,7 @@ class SyncCache(BaseCache):
return success return success
def wait(self, pubsub: SyncPubsSub, timeout: int = 30, **kwargs) -> Any: def wait(self, pubsub: "SyncPubsSub", timeout: int = 30, **kwargs) -> t.Any:
"""Wait for pub/sub messages & return posted message.""" """Wait for pub/sub messages & return posted message."""
now = time.time() now = time.time()
timeout = now + timeout timeout = now + timeout
@ -115,7 +149,7 @@ class SyncCache(BaseCache):
return None return None
def pubsub(self) -> SyncPubsSub: def pubsub(self) -> "SyncPubsSub":
"""Provide a redis.client.Pubsub instance.""" """Provide a redis.client.Pubsub instance."""
return self.instance.pubsub() return self.instance.pubsub()
@ -137,8 +171,20 @@ class SyncCache(BaseCache):
for key in keys: for key in keys:
self.instance.expire(key, seconds) self.instance.expire(key, seconds)
def get_config(self) -> Dict: def get_params(self) -> "Params":
"""Get picked config object from cache.""" """Get Params object from the cache."""
return self.get_object(self.CONFIG_KEY, "Params")
# return pickle.loads(self.get(self.CONFIG_KEY, decode=False, parse=False))
pickled = self.instance.get("HYPERGLASS_CONFIG") def get_devices(self) -> "Devices":
return pickle.loads(pickled) """Get Devices object from the cache."""
return self.get_object(self.DEVICES_KEY, "Devices")
# return pickle.loads(self.get(self.DEVICES_KEY, decode=False, parse=False))
def set_config(self: "SyncCache", config: "Params") -> None:
"""Add a params instance to the cache."""
self.instance.set(self.CONFIG_KEY, pickle.dumps(config))
def set_devices(self: "SyncCache", devices: "Devices") -> None:
"""Add a devices instance to the cache."""
self.instance.set(self.DEVICES_KEY, pickle.dumps(devices))

View file

@ -10,13 +10,8 @@ import yaml
from pydantic import ValidationError from pydantic import ValidationError
# Project # Project
from hyperglass.log import ( from hyperglass.log import log, enable_file_logging, enable_syslog_logging
log, from hyperglass.util import set_app_path, set_cache_env
set_log_level,
enable_file_logging,
enable_syslog_logging,
)
from hyperglass.util import set_app_path, set_cache_env, current_log_level
from hyperglass.defaults import CREDIT from hyperglass.defaults import CREDIT
from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__ from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__
from hyperglass.models.ui import UIParameters from hyperglass.models.ui import UIParameters
@ -135,20 +130,11 @@ def _get_devices(data: List[Dict], directives: List[Directive]) -> Devices:
user_config = _config_optional(CONFIG_MAIN) user_config = _config_optional(CONFIG_MAIN)
# Read raw debug value from config to enable debugging quickly. # Read raw debug value from config to enable debugging quickly.
set_log_level(logger=log, debug=user_config.get("debug", True))
# Map imported user configuration to expected schema. # Map imported user configuration to expected schema.
log.debug("Unvalidated configuration from {}: {}", CONFIG_MAIN, user_config) log.debug("Unvalidated configuration from {}: {}", CONFIG_MAIN, user_config)
params = validate_config(config=user_config, importer=Params) params = validate_config(config=user_config, importer=Params)
# Re-evaluate debug state after config is validated
log_level = current_log_level(log)
if params.debug and log_level != "debug":
set_log_level(logger=log, debug=True)
elif not params.debug and log_level == "debug":
set_log_level(logger=log, debug=False)
# Map imported user commands to expected schema. # Map imported user commands to expected schema.
_user_commands = _config_optional(CONFIG_COMMANDS) _user_commands = _config_optional(CONFIG_COMMANDS)
log.debug("Unvalidated commands from {}: {}", CONFIG_COMMANDS, _user_commands) log.debug("Unvalidated commands from {}: {}", CONFIG_COMMANDS, _user_commands)

View file

@ -92,3 +92,7 @@ class DependencyError(PrivateHyperglassError):
class PluginError(PrivateHyperglassError): class PluginError(PrivateHyperglassError):
"""Raised when a plugin error occurs.""" """Raised when a plugin error occurs."""
class StateError(PrivateHyperglassError):
"""Raised when an error occurs while fetching state from Redis."""

View file

@ -5,3 +5,10 @@ from .agent import AgentConnection
from ._common import Connection from ._common import Connection
from .ssh_netmiko import NetmikoConnection from .ssh_netmiko import NetmikoConnection
from .ssh_scrapli import ScrapliConnection from .ssh_scrapli import ScrapliConnection
__all__ = (
"AgentConnection",
"Connection",
"NetmikoConnection",
"ScrapliConnection",
)

View file

@ -8,28 +8,32 @@ hyperglass API modules.
# Standard Library # Standard Library
import re import re
import json as _json import json as _json
import typing as t
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.util import get_fmt_keys from hyperglass.util import get_fmt_keys
from hyperglass.constants import TRANSPORT_REST, TARGET_FORMAT_SPACE from hyperglass.constants import TRANSPORT_REST, TARGET_FORMAT_SPACE
from hyperglass.models.api.query import Query
from hyperglass.exceptions.public import InputInvalid from hyperglass.exceptions.public import InputInvalid
from hyperglass.exceptions.private import ConfigError from hyperglass.exceptions.private import ConfigError
from hyperglass.models.config.devices import Device
from hyperglass.models.commands.generic import Directive if t.TYPE_CHECKING:
# Project
from hyperglass.models.api.query import Query
from hyperglass.models.config.devices import Device
from hyperglass.models.commands.generic import Directive
class Construct: class Construct:
"""Construct SSH commands/REST API parameters from validated query data.""" """Construct SSH commands/REST API parameters from validated query data."""
directive: Directive directive: "Directive"
device: Device device: "Device"
query: Query query: "Query"
transport: str transport: str
target: str target: str
def __init__(self, device, query): def __init__(self, device: "Device", query: "Query"):
"""Initialize command construction.""" """Initialize command construction."""
log.debug( log.debug(
"Constructing '{}' query for '{}'", query.query_type, str(query.query_target), "Constructing '{}' query for '{}'", query.query_type, str(query.query_target),

View file

@ -16,8 +16,8 @@ import httpx
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.util import parse_exception from hyperglass.util import parse_exception
from hyperglass.state import use_state
from hyperglass.encode import jwt_decode, jwt_encode from hyperglass.encode import jwt_decode, jwt_encode
from hyperglass.configuration import params
from hyperglass.exceptions.public import RestError, ResponseEmpty from hyperglass.exceptions.public import RestError, ResponseEmpty
# Local # Local
@ -38,10 +38,11 @@ class AgentConnection(Connection):
async def collect(self) -> Iterable: # noqa: C901 async def collect(self) -> Iterable: # noqa: C901
"""Connect to a device running hyperglass-agent via HTTP.""" """Connect to a device running hyperglass-agent via HTTP."""
log.debug("Query parameters: {}", self.query) log.debug("Query parameters: {}", self.query)
state = use_state()
client_params = { client_params = {
"headers": {"Content-Type": "application/json"}, "headers": {"Content-Type": "application/json"},
"timeout": params.request_timeout, "timeout": state.params.request_timeout,
} }
if self.device.ssl is not None and self.device.ssl.enable: if self.device.ssl is not None and self.device.ssl.enable:
with self.device.ssl.cert.open("r") as file: with self.device.ssl.cert.open("r") as file:
@ -76,7 +77,7 @@ class AgentConnection(Connection):
encoded_query = await jwt_encode( encoded_query = await jwt_encode(
payload=query, payload=query,
secret=self.device.credential.password.get_secret_value(), secret=self.device.credential.password.get_secret_value(),
duration=params.request_timeout, duration=state.params.request_timeout,
) )
log.debug("Encoded JWT: {}", encoded_query) log.debug("Encoded JWT: {}", encoded_query)

View file

@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.configuration import params from hyperglass.state import use_state
from hyperglass.compat._sshtunnel import BaseSSHTunnelForwarderError, open_tunnel from hyperglass.compat._sshtunnel import BaseSSHTunnelForwarderError, open_tunnel
from hyperglass.exceptions.public import ScrapeError from hyperglass.exceptions.public import ScrapeError
@ -24,6 +24,7 @@ class SSHConnection(Connection):
"""Return a preconfigured sshtunnel.SSHTunnelForwarder instance.""" """Return a preconfigured sshtunnel.SSHTunnelForwarder instance."""
proxy = self.device.proxy proxy = self.device.proxy
state = use_state()
def opener(): def opener():
"""Set up an SSH tunnel according to a device's configuration.""" """Set up an SSH tunnel according to a device's configuration."""
@ -32,7 +33,7 @@ class SSHConnection(Connection):
"remote_bind_address": (self.device._target, self.device.port), "remote_bind_address": (self.device._target, self.device.port),
"local_bind_address": ("localhost", 0), "local_bind_address": ("localhost", 0),
"skip_tunnel_checkup": False, "skip_tunnel_checkup": False,
"gateway_timeout": params.request_timeout - 2, "gateway_timeout": state.params.request_timeout - 2,
} }
if proxy.credential._method == "password": if proxy.credential._method == "password":
# Use password auth if no key is defined. # Use password auth if no key is defined.

View file

@ -16,7 +16,7 @@ from netmiko import ( # type: ignore
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.configuration import params from hyperglass.state import state
from hyperglass.exceptions.public import AuthError, DeviceTimeout, ResponseEmpty from hyperglass.exceptions.public import AuthError, DeviceTimeout, ResponseEmpty
# Local # Local
@ -65,9 +65,9 @@ class NetmikoConnection(SSHConnection):
"port": port or self.device.port, "port": port or self.device.port,
"device_type": self.device.type, "device_type": self.device.type,
"username": self.device.credential.username, "username": self.device.credential.username,
"global_delay_factor": params.netmiko_delay_factor, "global_delay_factor": state.params.netmiko_delay_factor,
"timeout": math.floor(params.request_timeout * 1.25), "timeout": math.floor(state.params.request_timeout * 1.25),
"session_timeout": math.ceil(params.request_timeout - 1), "session_timeout": math.ceil(state.params.request_timeout - 1),
**global_args, **global_args,
} }

View file

@ -24,7 +24,7 @@ from scrapli.driver.core import (
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.configuration import params from hyperglass.state import use_state
from hyperglass.exceptions.public import ( from hyperglass.exceptions.public import (
AuthError, AuthError,
ScrapeError, ScrapeError,
@ -71,6 +71,7 @@ class ScrapliConnection(SSHConnection):
Directly connects to the router via Netmiko library, returns the Directly connects to the router via Netmiko library, returns the
command output. command output.
""" """
state = use_state()
driver = _map_driver(self.device.type) driver = _map_driver(self.device.type)
if host is not None: if host is not None:
@ -89,7 +90,7 @@ class ScrapliConnection(SSHConnection):
"host": host or self.device._target, "host": host or self.device._target,
"port": port or self.device.port, "port": port or self.device.port,
"auth_username": self.device.credential.username, "auth_username": self.device.credential.username,
"timeout_ops": math.floor(params.request_timeout * 1.25), "timeout_ops": math.floor(state.params.request_timeout * 1.25),
"transport": "asyncssh", "transport": "asyncssh",
"auth_strict_key": False, "auth_strict_key": False,
"ssh_known_hosts_file": False, "ssh_known_hosts_file": False,

View file

@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Dict, Union, Callable
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.configuration import params from hyperglass.state import use_state
from hyperglass.exceptions.public import DeviceTimeout, ResponseEmpty from hyperglass.exceptions.public import DeviceTimeout, ResponseEmpty
if TYPE_CHECKING: if TYPE_CHECKING:
@ -47,8 +47,8 @@ def handle_timeout(**exc_args: Any) -> Callable:
async def execute(query: "Query") -> Union["OutputDataModel", str]: async def execute(query: "Query") -> Union["OutputDataModel", str]:
"""Initiate query validation and execution.""" """Initiate query validation and execution."""
state = use_state()
output = params.messages.general output = state.params.messages.general
log.debug("Received query for {}", query.json()) log.debug("Received query for {}", query.json())
log.debug("Matched device config: {}", query.device) log.debug("Matched device config: {}", query.device)
@ -60,7 +60,7 @@ async def execute(query: "Query") -> Union["OutputDataModel", str]:
signal.SIGALRM, signal.SIGALRM,
handle_timeout(error=TimeoutError("Connection timed out"), device=query.device), handle_timeout(error=TimeoutError("Connection timed out"), device=query.device),
) )
signal.alarm(params.request_timeout - 1) signal.alarm(state.params.request_timeout - 1)
if query.device.proxy: if query.device.proxy:
proxy = driver.setup_proxy() proxy = driver.setup_proxy()

View file

@ -2,18 +2,17 @@
# Standard Library # Standard Library
import sys import sys
import math
import shutil import shutil
import typing as t
import logging import logging
import platform import platform
from typing import TYPE_CHECKING
# Third Party # Third Party
from gunicorn.app.base import BaseApplication # type: ignore from gunicorn.app.base import BaseApplication # type: ignore
from gunicorn.glogging import Logger # type: ignore from gunicorn.glogging import Logger # type: ignore
# Local # Local
from .log import log, setup_lib_logging from .log import log, set_log_level, setup_lib_logging
from .plugins import ( from .plugins import (
InputPluginManager, InputPluginManager,
OutputPluginManager, OutputPluginManager,
@ -23,7 +22,7 @@ from .plugins import (
from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__ from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__
from .util.frontend import get_node_version from .util.frontend import get_node_version
if TYPE_CHECKING: if t.TYPE_CHECKING:
# Third Party # Third Party
from gunicorn.arbiter import Arbiter # type: ignore from gunicorn.arbiter import Arbiter # type: ignore
@ -39,33 +38,19 @@ if sys.version_info < MIN_PYTHON_VERSION:
node_major, _, __ = get_node_version() node_major, _, __ = get_node_version()
if node_major != MIN_NODE_VERSION: if node_major != MIN_NODE_VERSION:
raise RuntimeError(f"NodeJS {MIN_NODE_VERSION}+ is required.") raise RuntimeError(f"NodeJS {MIN_NODE_VERSION!s}+ is required.")
# Project # Project
from hyperglass.compat._asyncio import aiorun from hyperglass.compat._asyncio import aiorun
# Local # Local
from .util import cpu_count, clear_redis_cache, format_listen_address from .util import cpu_count
from .cache import SyncCache from .state import use_state
from .configuration import ( from .settings import Settings
URL_DEV, from .configuration import URL_DEV, URL_PROD
URL_PROD,
CONFIG_PATH,
REDIS_CONFIG,
params,
devices,
ui_params,
)
from .util.frontend import build_frontend from .util.frontend import build_frontend
if params.debug:
workers = 1
loglevel = "DEBUG"
else:
workers = cpu_count(2)
loglevel = "WARNING"
class StubbedGunicornLogger(Logger): class StubbedGunicornLogger(Logger):
"""Custom logging to direct Gunicorn/Uvicorn logs to Loguru/Rich. """Custom logging to direct Gunicorn/Uvicorn logs to Loguru/Rich.
@ -73,58 +58,30 @@ class StubbedGunicornLogger(Logger):
See: https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/ See: https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/
""" """
def setup(self, cfg): def setup(self, cfg: t.Any) -> None:
"""Override Gunicorn setup.""" """Override Gunicorn setup."""
handler = logging.NullHandler() handler = logging.NullHandler()
self.error_logger = logging.getLogger("gunicorn.error") self.error_logger = logging.getLogger("gunicorn.error")
self.error_logger.addHandler(handler) self.error_logger.addHandler(handler)
self.access_logger = logging.getLogger("gunicorn.access") self.access_logger = logging.getLogger("gunicorn.access")
self.access_logger.addHandler(handler) self.access_logger.addHandler(handler)
self.error_logger.setLevel(loglevel) self.error_logger.setLevel(Settings.log_level)
self.access_logger.setLevel(loglevel) self.access_logger.setLevel(Settings.log_level)
def check_redis_instance() -> bool:
"""Ensure Redis is running before starting server."""
cache = SyncCache(db=params.cache.database, **REDIS_CONFIG)
cache.test()
log.debug("Redis is running at: {}:{}", REDIS_CONFIG["host"], REDIS_CONFIG["port"])
return True
async def build_ui() -> bool: async def build_ui() -> bool:
"""Perform a UI build prior to starting the application.""" """Perform a UI build prior to starting the application."""
state = use_state()
await build_frontend( await build_frontend(
dev_mode=params.developer_mode, dev_mode=Settings.dev_mode,
dev_url=URL_DEV, dev_url=URL_DEV,
prod_url=URL_PROD, prod_url=URL_PROD,
params=ui_params, params=state.ui_params,
app_path=CONFIG_PATH, app_path=Settings.app_path,
) )
return True return True
async def clear_cache():
"""Clear the Redis cache on shutdown."""
try:
await clear_redis_cache(db=params.cache.database, config=REDIS_CONFIG)
except RuntimeError as e:
log.error(str(e))
pass
def cache_config() -> bool:
"""Add configuration to Redis cache as a pickled object."""
# Standard Library
import pickle
cache = SyncCache(db=params.cache.database, **REDIS_CONFIG)
cache.set("HYPERGLASS_CONFIG", pickle.dumps(params))
return True
def register_all_plugins(devices: "Devices") -> None: def register_all_plugins(devices: "Devices") -> None:
"""Validate and register configured plugins.""" """Validate and register configured plugins."""
@ -149,23 +106,21 @@ def unregister_all_plugins() -> None:
def on_starting(server: "Arbiter"): def on_starting(server: "Arbiter"):
"""Gunicorn pre-start tasks.""" """Gunicorn pre-start tasks."""
setup_lib_logging()
python_version = platform.python_version() python_version = platform.python_version()
required = ".".join((str(v) for v in MIN_PYTHON_VERSION)) required = ".".join((str(v) for v in MIN_PYTHON_VERSION))
log.info("Python {} detected ({} required)", python_version, required) log.debug("Python {} detected ({} required)", python_version, required)
state = use_state()
register_all_plugins(state.devices)
check_redis_instance()
aiorun(build_ui()) aiorun(build_ui())
cache_config()
register_all_plugins(devices)
log.success( log.success(
"Started hyperglass {v} on http://{h}:{p} with {w} workers", "Started hyperglass {} on http://{} with {!s} workers",
v=__version__, __version__,
h=format_listen_address(params.listen_address), Settings.bind(),
p=str(params.listen_port), server.app.cfg.settings["workers"].value,
w=server.app.cfg.settings["workers"].value,
) )
@ -174,11 +129,10 @@ def on_exit(server: "Arbiter"):
log.critical("Stopping hyperglass {}", __version__) log.critical("Stopping hyperglass {}", __version__)
async def runner(): state = use_state()
if not params.developer_mode: if not Settings.dev_mode:
await clear_cache() state.clear()
aiorun(runner())
unregister_all_plugins() unregister_all_plugins()
@ -210,24 +164,29 @@ class HyperglassWSGI(BaseApplication):
def start(**kwargs): def start(**kwargs):
"""Start hyperglass via gunicorn.""" """Start hyperglass via gunicorn."""
set_log_level(log, Settings.debug)
log.debug("System settings: {!r}", Settings)
setup_lib_logging()
workers, log_level = 1, "DEBUG"
if Settings.debug is False:
workers, log_level = cpu_count(2), "WARNING"
HyperglassWSGI( HyperglassWSGI(
app="hyperglass.api:app", app="hyperglass.api:app",
options={ options={
"worker_class": "uvicorn.workers.UvicornWorker",
"preload": True, "preload": True,
"keepalive": 10,
"command": shutil.which("gunicorn"),
"bind": ":".join(
(format_listen_address(params.listen_address), str(params.listen_port))
),
"workers": workers,
"loglevel": loglevel,
"timeout": math.ceil(params.request_timeout * 1.25),
"on_starting": on_starting,
"on_exit": on_exit,
"logger_class": StubbedGunicornLogger,
"accesslog": "-",
"errorlog": "-", "errorlog": "-",
"accesslog": "-",
"workers": workers,
"on_exit": on_exit,
"loglevel": log_level,
"bind": Settings.bind(),
"on_starting": on_starting,
"command": shutil.which("gunicorn"),
"logger_class": StubbedGunicornLogger,
"worker_class": "uvicorn.workers.UvicornWorker",
"logconfig_dict": {"formatters": {"generic": {"format": "%(message)s"}}}, "logconfig_dict": {"formatters": {"generic": {"format": "%(message)s"}}},
**kwargs, **kwargs,
}, },
@ -235,4 +194,12 @@ def start(**kwargs):
if __name__ == "__main__": if __name__ == "__main__":
start() try:
start()
except Exception as error:
if not Settings.dev_mode:
state = use_state()
state.clear()
log.info("Cleared Redis cache")
unregister_all_plugins()
raise error

View file

@ -3,7 +3,7 @@
# Standard Library # Standard Library
import os import os
import re import re
from typing import Dict, List, Union, Literal, Optional import typing as t
from pathlib import Path from pathlib import Path
from ipaddress import IPv4Network, IPv6Network, ip_network from ipaddress import IPv4Network, IPv6Network, ip_network
@ -25,15 +25,18 @@ from hyperglass.exceptions.private import InputValidationError
# Local # Local
from ..main import HyperglassModel, HyperglassModelWithId from ..main import HyperglassModel, HyperglassModelWithId
from ..fields import Action from ..fields import Action
from ..config.params import Params
if t.TYPE_CHECKING:
# Local
from ..config.params import Params
IPv4PrefixLength = conint(ge=0, le=32) IPv4PrefixLength = conint(ge=0, le=32)
IPv6PrefixLength = conint(ge=0, le=128) IPv6PrefixLength = conint(ge=0, le=128)
IPNetwork = Union[IPv4Network, IPv6Network] IPNetwork = t.Union[IPv4Network, IPv6Network]
StringOrArray = Union[StrictStr, List[StrictStr]] StringOrArray = t.Union[StrictStr, t.List[StrictStr]]
Condition = Union[IPv4Network, IPv6Network, StrictStr] Condition = t.Union[IPv4Network, IPv6Network, StrictStr]
RuleValidation = Union[Literal["ipv4", "ipv6", "pattern"], None] RuleValidation = t.Union[t.Literal["ipv4", "ipv6", "pattern"], None]
PassedValidation = Union[bool, None] PassedValidation = t.Union[bool, None]
class Input(HyperglassModel): class Input(HyperglassModel):
@ -57,14 +60,14 @@ class Text(Input):
"""Text/input field model.""" """Text/input field model."""
_type: PrivateAttr = PrivateAttr("text") _type: PrivateAttr = PrivateAttr("text")
validation: Optional[StrictStr] validation: t.Optional[StrictStr]
class Option(HyperglassModel): class Option(HyperglassModel):
"""Select option model.""" """Select option model."""
name: Optional[StrictStr] name: t.Optional[StrictStr]
description: Optional[StrictStr] description: t.Optional[StrictStr]
value: StrictStr value: StrictStr
@ -72,7 +75,7 @@ class Select(Input):
"""Select field model.""" """Select field model."""
_type: PrivateAttr = PrivateAttr("select") _type: PrivateAttr = PrivateAttr("select")
options: List[Option] options: t.List[Option]
class Rule(HyperglassModel, allow_population_by_field_name=True): class Rule(HyperglassModel, allow_population_by_field_name=True):
@ -82,10 +85,10 @@ class Rule(HyperglassModel, allow_population_by_field_name=True):
_passed: PassedValidation = PrivateAttr(None) _passed: PassedValidation = PrivateAttr(None)
condition: Condition condition: Condition
action: Action = Action("permit") action: Action = Action("permit")
commands: List[str] = Field([], alias="command") commands: t.List[str] = Field([], alias="command")
@validator("commands", pre=True, allow_reuse=True) @validator("commands", pre=True, allow_reuse=True)
def validate_commands(cls, value: Union[str, List[str]]) -> List[str]: def validate_commands(cls, value: t.Union[str, t.List[str]]) -> t.List[str]:
"""Ensure commands is a list.""" """Ensure commands is a list."""
if isinstance(value, str): if isinstance(value, str):
return [value] return [value]
@ -215,13 +218,13 @@ class RuleWithoutValidation(Rule):
_validation: RuleValidation = PrivateAttr(None) _validation: RuleValidation = PrivateAttr(None)
condition: None condition: None
def validate_target(self, target: str) -> Literal[True]: def validate_target(self, target: str) -> t.Literal[True]:
"""Don't validate a target. Always returns `True`.""" """Don't validate a target. Always returns `True`."""
self._passed = True self._passed = True
return True return True
Rules = Union[RuleWithIPv4, RuleWithIPv6, RuleWithPattern, RuleWithoutValidation] Rules = t.Union[RuleWithIPv4, RuleWithIPv6, RuleWithPattern, RuleWithoutValidation]
class Directive(HyperglassModelWithId): class Directive(HyperglassModelWithId):
@ -229,11 +232,12 @@ class Directive(HyperglassModelWithId):
id: StrictStr id: StrictStr
name: StrictStr name: StrictStr
rules: List[Rules] rules: t.List[Rules]
field: Union[Text, Select, None] field: t.Union[Text, Select, None]
info: Optional[FilePath] info: t.Optional[FilePath]
plugins: List[StrictStr] = [] plugins: t.List[StrictStr] = []
groups: List[ disable_builtins: StrictBool = False
groups: t.List[
StrictStr StrictStr
] = [] # TODO: Flesh this out. Replace VRFs, but use same logic in React to filter available commands for multi-device queries. ] = [] # TODO: Flesh this out. Replace VRFs, but use same logic in React to filter available commands for multi-device queries.
@ -247,7 +251,7 @@ class Directive(HyperglassModelWithId):
raise InputValidationError(error="No matched validation rules", target=target) raise InputValidationError(error="No matched validation rules", target=target)
@property @property
def field_type(self) -> Literal["text", "select", None]: def field_type(self) -> t.Literal["text", "select", None]:
"""Get the linked field type.""" """Get the linked field type."""
if self.field.is_select: if self.field.is_select:
@ -257,7 +261,7 @@ class Directive(HyperglassModelWithId):
return None return None
@validator("plugins") @validator("plugins")
def validate_plugins(cls: "Directive", plugins: List[str]) -> List[str]: def validate_plugins(cls: "Directive", plugins: t.List[str]) -> t.List[str]:
"""Validate and register configured plugins.""" """Validate and register configured plugins."""
plugin_dir = Path(os.environ["hyperglass_directory"]) / "plugins" plugin_dir = Path(os.environ["hyperglass_directory"]) / "plugins"
if plugin_dir.exists(): if plugin_dir.exists():
@ -271,7 +275,7 @@ class Directive(HyperglassModelWithId):
return [str(f) for f in matching_plugins] return [str(f) for f in matching_plugins]
return [] return []
def frontend(self, params: Params) -> Dict: def frontend(self: "Directive", params: "Params") -> t.Dict[str, t.Any]:
"""Prepare a representation of the directive for the UI.""" """Prepare a representation of the directive for the UI."""
value = { value = {

116
hyperglass/models/system.py Normal file
View file

@ -0,0 +1,116 @@
"""hyperglass System Settings model."""
# Standard Library
import typing as t
from ipaddress import ip_address
# Third Party
from pydantic import (
RedisDsn,
SecretStr,
BaseSettings,
DirectoryPath,
IPvAnyAddress,
validator,
)
# Project
from hyperglass.util import at_least, cpu_count
ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]]
class HyperglassSystem(BaseSettings):
"""hyperglass system settings, required to start hyperglass."""
class Config:
"""hyperglass system settings configuration."""
env_prefix = "hyperglass_"
debug: bool = False
dev_mode: bool = False
app_path: DirectoryPath
redis_host: str = "localhost"
redis_password: t.Optional[SecretStr]
redis_db: int = 1
redis_dsn: RedisDsn = None
host: IPvAnyAddress = None
port: int = 8001
@validator("host", pre=True, always=True)
def validate_host(
cls: "HyperglassSystem", value: t.Any, values: t.Dict[str, t.Any]
) -> IPvAnyAddress:
"""Set default host based on debug mode."""
if value is None:
if values["debug"] is False:
return ip_address("127.0.0.1")
elif values["debug"] is True:
return ip_address("0.0.0.0")
if isinstance(value, str):
if value != "localhost":
try:
return ip_address(value)
except ValueError:
raise ValueError(str(value))
elif value == "localhost":
return ip_address("127.0.0.1")
raise ValueError(str(value))
@validator("redis_dsn", always=True)
def validate_redis_dsn(
cls: "HyperglassSystem", value: t.Any, values: t.Dict[str, t.Any]
) -> RedisDsn:
"""Construct a Redis DSN if none is provided."""
if value is None:
dsn = "redis://{}/{!s}".format(values["redis_host"], values["redis_db"])
password = values.get("redis_password")
if password is not None:
dsn = "redis://:{}@{}/{!s}".format(
password.get_secret_value(), values["redis_host"], values["redis_db"],
)
return dsn
return value
def bind(self: "HyperglassSystem") -> 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: "HyperglassSystem") -> str:
"""Get log level as string, inferred from debug mode."""
if self.debug:
return "DEBUG"
return "WARNING"
@property
def workers(self: "HyperglassSystem") -> int:
"""Get worker count, inferred from debug mode."""
if self.debug:
return 1
return cpu_count(2)
@property
def redis(self: "HyperglassSystem") -> 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: "HyperglassSystem") -> t.Dict[str, t.Any]:
"""Get Redis ConnectionPool keyword arguments."""
return {"url": str(self.redis_dsn), "max_connections": at_least(8, cpu_count(2))}

View file

@ -1,16 +1,12 @@
"""Plugin manager definition.""" """Plugin manager definition."""
# Standard Library # Standard Library
import json import typing as t
import codecs
import pickle
from typing import TYPE_CHECKING, Any, List, Generic, TypeVar, Callable, Generator
from inspect import isclass from inspect import isclass
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.cache import SyncCache from hyperglass.state.redis import use_state
from hyperglass.configuration import REDIS_CONFIG, params
from hyperglass.exceptions.private import PluginError from hyperglass.exceptions.private import PluginError
# Local # Local
@ -18,26 +14,27 @@ from ._base import PluginType, HyperglassPlugin
from ._input import InputPlugin, InputPluginReturn from ._input import InputPlugin, InputPluginReturn
from ._output import OutputType, OutputPlugin from ._output import OutputType, OutputPlugin
if TYPE_CHECKING: if t.TYPE_CHECKING:
# Project # Project
from hyperglass.state.redis import HyperglassState
from hyperglass.models.api.query import Query from hyperglass.models.api.query import Query
from hyperglass.models.config.devices import Device from hyperglass.models.config.devices import Device
from hyperglass.models.commands.generic import Directive from hyperglass.models.commands.generic import Directive
PluginT = TypeVar("PluginT") PluginT = t.TypeVar("PluginT", bound=HyperglassPlugin)
class PluginManager(Generic[PluginT]): class PluginManager(t.Generic[PluginT]):
"""Manage all plugins.""" """Manage all plugins."""
_type: PluginType _type: PluginType
_cache: SyncCache _state: "HyperglassState"
_index: int = 0 _index: int = 0
_cache_key: str _cache_key: str
def __init__(self: "PluginManager") -> None: def __init__(self: "PluginManager") -> None:
"""Initialize plugin manager.""" """Initialize plugin manager."""
self._cache = SyncCache(db=params.cache.database, **REDIS_CONFIG) self._state = use_state()
self._cache_key = f"hyperglass.plugins.{self._type}" self._cache_key = f"hyperglass.plugins.{self._type}"
def __init_subclass__(cls: "PluginManager", **kwargs: PluginType) -> None: def __init_subclass__(cls: "PluginManager", **kwargs: PluginType) -> None:
@ -61,20 +58,19 @@ class PluginManager(Generic[PluginT]):
self._index = 0 self._index = 0
raise StopIteration raise StopIteration
def _get_plugins(self: "PluginManager") -> List[PluginT]:
"""Retrieve plugins from cache."""
cached = self._cache.get(self._cache_key)
return list({pickle.loads(codecs.decode(plugin.encode(), "base64")) for plugin in cached})
def _clear_plugins(self: "PluginManager") -> None:
"""Remove all plugins."""
self._cache.set(self._cache_key, json.dumps([]))
@property @property
def plugins(self: "PluginManager") -> List[PluginT]: def plugins(self: "PluginManager", builtins: bool = True) -> t.List[PluginT]:
"""Get all plugins, with built-in plugins last.""" """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( return sorted(
self._get_plugins(), sorted_by_name,
key=lambda p: -1 if p.__hyperglass_builtin__ else 1, # flake8: noqa IF100 key=lambda p: -1 if p.__hyperglass_builtin__ else 1, # flake8: noqa IF100
reverse=True, reverse=True,
) )
@ -84,7 +80,7 @@ class PluginManager(Generic[PluginT]):
"""Get this plugin manager's name.""" """Get this plugin manager's name."""
return self.__class__.__name__ return self.__class__.__name__
def methods(self: "PluginManager", name: str) -> Generator[Callable, None, None]: def methods(self: "PluginManager", name: str) -> t.Generator[t.Callable, None, None]:
"""Get methods of all registered plugins matching `name`.""" """Get methods of all registered plugins matching `name`."""
for plugin in self.plugins: for plugin in self.plugins:
if hasattr(plugin, name): if hasattr(plugin, name):
@ -99,39 +95,24 @@ class PluginManager(Generic[PluginT]):
def reset(self: "PluginManager") -> None: def reset(self: "PluginManager") -> None:
"""Remove all plugins.""" """Remove all plugins."""
self._index = 0 self._index = 0
self._cache = SyncCache(db=params.cache.database, **REDIS_CONFIG) self._state.reset_plugins(self._type)
return self._clear_plugins()
def unregister(self: "PluginManager", plugin: PluginT) -> None: def unregister(self: "PluginManager", plugin: PluginT) -> None:
"""Remove a plugin from currently active plugins.""" """Remove a plugin from currently active plugins."""
if isclass(plugin): if isclass(plugin):
if issubclass(plugin, HyperglassPlugin): if issubclass(plugin, HyperglassPlugin):
plugins = { self._state.remove_plugin(self._type, plugin)
# Create a base64 representation of a picked plugin.
codecs.encode(pickle.dumps(p), "base64").decode()
# Merge current plugins with the new plugin.
for p in self._get_plugins()
if p != plugin
}
# Add plugins from cache.
self._cache.set(f"hyperglass.plugins.{self._type}", json.dumps(list(plugins)))
return return
raise PluginError("Plugin '{}' is not a valid hyperglass plugin", repr(plugin)) raise PluginError("Plugin '{}' is not a valid hyperglass plugin", repr(plugin))
def register(self: "PluginManager", plugin: PluginT, *args: Any, **kwargs: Any) -> None: def register(self: "PluginManager", plugin: PluginT, *args: t.Any, **kwargs: t.Any) -> None:
"""Add a plugin to currently active plugins.""" """Add a plugin to currently active plugins."""
# Create a set of plugins so duplicate plugins are not mistakenly added. # Create a set of plugins so duplicate plugins are not mistakenly added.
try: try:
if issubclass(plugin, HyperglassPlugin): if issubclass(plugin, HyperglassPlugin):
instance = plugin(*args, **kwargs) instance = plugin(*args, **kwargs)
plugins = { self._state.add_plugin(self._type, instance)
# Create a base64 representation of a picked plugin.
codecs.encode(pickle.dumps(p), "base64").decode()
# Merge current plugins with the new plugin.
for p in [*self._get_plugins(), instance]
}
# Add plugins from cache.
self._cache.set(f"hyperglass.plugins.{self._type}", json.dumps(list(plugins)))
if instance.__hyperglass_builtin__ is True: if instance.__hyperglass_builtin__ is True:
log.debug("Registered built-in plugin '{}'", instance.name) log.debug("Registered built-in plugin '{}'", instance.name)
else: else:

View file

@ -34,6 +34,7 @@ def _register_from_module(module: Any, **kwargs: Any) -> Tuple[str, ...]:
"""Register defined classes from the module.""" """Register defined classes from the module."""
failures = () failures = ()
defs = getmembers(module, lambda o: _is_class(module, o)) defs = getmembers(module, lambda o: _is_class(module, o))
sys.modules[module.__name__] = module
for name, plugin in defs: for name, plugin in defs:
if issubclass(plugin, OutputPlugin): if issubclass(plugin, OutputPlugin):
manager = OutputPluginManager() manager = OutputPluginManager()
@ -55,7 +56,6 @@ def _module_from_file(file: Path) -> Any:
for k, v in _PLUGIN_GLOBALS.items(): for k, v in _PLUGIN_GLOBALS.items():
setattr(module, k, v) setattr(module, k, v)
spec.loader.exec_module(module) spec.loader.exec_module(module)
sys.modules[module.__name__] = module
return module return module

17
hyperglass/settings.py Normal file
View file

@ -0,0 +1,17 @@
# Standard Library
import typing as t
if t.TYPE_CHECKING:
# Local
from .models.system import HyperglassSystem
def _system_settings() -> "HyperglassSystem":
"""Get system settings from local environment."""
# Local
from .models.system import HyperglassSystem
return HyperglassSystem()
Settings = _system_settings()

View file

@ -0,0 +1,6 @@
"""hyperglass global state management."""
# Local
from .redis import use_state
__all__ = ("use_state",)

133
hyperglass/state/redis.py Normal file
View file

@ -0,0 +1,133 @@
"""hyperglass global state."""
# Standard Library
import codecs
import pickle
import typing as t
from functools import lru_cache
# Third Party
from redis import Redis, ConnectionPool
# Project
from hyperglass.configuration import params, devices, ui_params
from hyperglass.exceptions.private import StateError
# Local
from ..settings import Settings
if t.TYPE_CHECKING:
# Project
from hyperglass.models.ui import UIParameters
from hyperglass.models.system import HyperglassSystem
from hyperglass.plugins._base import HyperglassPlugin
from hyperglass.models.config.params import Params
from hyperglass.models.config.devices import Devices
PluginT = t.TypeVar("PluginT", bound="HyperglassPlugin")
class HyperglassState:
"""Global State Manager.
Maintains configuration objects in Redis cache and accesses them as needed.
"""
settings: "HyperglassSystem"
redis: Redis
_connection_pool: ConnectionPool
_namespace: str = "hyperglass.state"
def __init__(self, *, settings: "HyperglassSystem") -> None:
"""Set up Redis connection and add configuration objects."""
self.settings = settings
self._connection_pool = ConnectionPool.from_url(**self.settings.redis_connection_pool)
self.redis = Redis(connection_pool=self._connection_pool)
# Add configuration objects.
self.set_object("params", params)
self.set_object("devices", devices)
self.set_object("ui_params", ui_params)
# Ensure plugins are empty.
self.reset_plugins("output")
self.reset_plugins("input")
def key(self, *keys: str) -> str:
"""Format keys with state namespace."""
return ".".join((*self._namespace.split("."), *keys))
def get_object(self, name: str, raise_if_none: bool = False) -> t.Any:
"""Get an object (class instance) from the cache."""
value = self.redis.get(name)
if isinstance(value, bytes):
return pickle.loads(value)
elif isinstance(value, str):
return pickle.loads(value.encode())
if raise_if_none is True:
raise StateError("'{key}' does not exist in Redis store", key=name)
return None
def set_object(self, name: str, obj: t.Any) -> None:
"""Add an object (class instance) to the cache."""
value = pickle.dumps(obj)
self.redis.set(self.key(name), value)
def add_plugin(self, _type: str, plugin: "HyperglassPlugin") -> None:
"""Add a plugin to its list by type."""
current = self.plugins(_type)
plugins = {
# Create a base64 representation of a picked plugin.
codecs.encode(pickle.dumps(p), "base64").decode()
# Merge current plugins with the new plugin.
for p in [*current, plugin]
}
self.set_object(self.key("plugins", _type), list(plugins))
def remove_plugin(self, _type: str, plugin: "HyperglassPlugin") -> None:
"""Remove a plugin from its list by type."""
current = self.plugins(_type)
plugins = {
# Create a base64 representation of a picked plugin.
codecs.encode(pickle.dumps(p), "base64").decode()
# Merge current plugins with the new plugin.
for p in current
if p != plugin
}
self.set_object(self.key("plugins", _type), list(plugins))
def reset_plugins(self, _type: str) -> None:
"""Remove all plugins of `_type`."""
self.set_object(self.key("plugins", _type), [])
def clear(self) -> None:
"""Delete all cache keys."""
self.redis.flushdb(asynchronous=True)
@property
def params(self) -> "Params":
"""Get hyperglass configuration parameters (`hyperglass.yaml`)."""
return self.get_object(self.key("params"), raise_if_none=True)
@property
def devices(self) -> "Devices":
"""Get hyperglass devices (`devices.yaml`)."""
return self.get_object(self.key("devices"), raise_if_none=True)
@property
def ui_params(self) -> "UIParameters":
"""UI parameters, built from params."""
return self.get_object(self.key("ui_params"), raise_if_none=True)
def plugins(self, _type: str) -> t.List[PluginT]:
"""Get plugins by type."""
current = self.get_object(self.key("plugins", _type), raise_if_none=False) or []
return list({pickle.loads(codecs.decode(plugin.encode(), "base64")) for plugin in current})
@lru_cache(maxsize=None)
def use_state() -> "HyperglassState":
"""Access hyperglass global state."""
return HyperglassState(settings=Settings)

View file

@ -146,23 +146,6 @@ to access the following directories:
return matched_path return matched_path
def format_listen_address(listen_address: Union[IPv4Address, IPv6Address, str]) -> str:
"""Format a listen_address. Wraps IPv6 address in brackets."""
fmt = str(listen_address)
if isinstance(listen_address, str):
try:
listen_address = ip_address(listen_address)
except ValueError as err:
log.error(err)
pass
if isinstance(listen_address, (IPv4Address, IPv6Address)) and listen_address.version == 6:
fmt = f"[{str(listen_address)}]"
return fmt
def split_on_uppercase(s): def split_on_uppercase(s):
"""Split characters by uppercase letters. """Split characters by uppercase letters.
@ -363,3 +346,10 @@ def deep_convert_keys(_dict: Type[DeepConvert], predicate: Callable[[str], str])
converted[predicate(key)] = get_value(value) converted[predicate(key)] = get_value(value)
return converted 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

View file

@ -5,20 +5,23 @@ import os
import json import json
import math import math
import shutil import shutil
import typing as t
import asyncio import asyncio
import subprocess import subprocess
from typing import Dict, Tuple, Optional
from pathlib import Path from pathlib import Path
# Project # Project
from hyperglass.log import log from hyperglass.log import log
from hyperglass.models.ui import UIParameters
# Local # Local
from .files import copyfiles, check_path from .files import copyfiles, check_path
if t.TYPE_CHECKING:
# Project
from hyperglass.models.ui import UIParameters
def get_node_version() -> Tuple[int, int, int]:
def get_node_version() -> t.Tuple[int, int, int]:
"""Get the system's NodeJS version.""" """Get the system's NodeJS version."""
node_path = shutil.which("node") node_path = shutil.which("node")
@ -30,7 +33,7 @@ def get_node_version() -> Tuple[int, int, int]:
return tuple((int(v) for v in version.split("."))) return tuple((int(v) for v in version.split(".")))
def get_ui_build_timeout() -> Optional[int]: def get_ui_build_timeout() -> t.Optional[int]:
"""Read the UI build timeout from environment variables or set a default.""" """Read the UI build timeout from environment variables or set a default."""
timeout = None timeout = None
@ -60,7 +63,7 @@ async def check_node_modules() -> bool:
return valid return valid
async def read_package_json() -> Dict: async def read_package_json() -> t.Dict[str, t.Any]:
"""Import package.json as a python dict.""" """Import package.json as a python dict."""
package_json_file = Path(__file__).parent.parent / "ui" / "package.json" package_json_file = Path(__file__).parent.parent / "ui" / "package.json"
@ -114,7 +117,7 @@ async def node_initial(timeout: int = 180, dev_mode: bool = False) -> str:
return "\n".join(all_messages) return "\n".join(all_messages)
async def build_ui(app_path): async def build_ui(app_path: Path):
"""Execute `next build` & `next export` from UI directory. """Execute `next build` & `next export` from UI directory.
Raises: Raises:
@ -216,7 +219,7 @@ def generate_opengraph(
return True return True
def migrate_images(app_path: Path, params: UIParameters): def migrate_images(app_path: Path, params: "UIParameters"):
"""Migrate images from source code to install directory.""" """Migrate images from source code to install directory."""
images_dir = app_path / "static" / "images" images_dir = app_path / "static" / "images"
favicon_dir = images_dir / "favicons" favicon_dir = images_dir / "favicons"
@ -236,7 +239,7 @@ async def build_frontend( # noqa: C901
dev_mode: bool, dev_mode: bool,
dev_url: str, dev_url: str,
prod_url: str, prod_url: str,
params: UIParameters, params: "UIParameters",
app_path: Path, app_path: Path,
force: bool = False, force: bool = False,
timeout: int = 180, timeout: int = 180,
@ -264,8 +267,6 @@ async def build_frontend( # noqa: C901
# Project # Project
from hyperglass.constants import __version__ from hyperglass.constants import __version__
log.info("Starting UI build")
# Create temporary file. json file extension is added for easy # Create temporary file. json file extension is added for easy
# webpack JSON parsing. # webpack JSON parsing.
env_file = Path("/tmp/hyperglass.env.json") # noqa: S108 env_file = Path("/tmp/hyperglass.env.json") # noqa: S108
@ -344,6 +345,7 @@ async def build_frontend( # noqa: C901
# Initiate Next.JS export process. # Initiate Next.JS export process.
if any((not dev_mode, force, full)): if any((not dev_mode, force, full)):
log.info("Starting UI build")
initialize_result = await node_initial(timeout, dev_mode) initialize_result = await node_initial(timeout, dev_mode)
build_result = await build_ui(app_path=app_path) build_result = await build_ui(app_path=app_path)

34
poetry.lock generated
View file

@ -6,6 +6,21 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "aioredis"
version = "2.0.0"
description = "asyncio (PEP 3156) Redis support"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
async-timeout = "*"
typing-extensions = "*"
[package.extras]
hiredis = ["hiredis (>=1.0)"]
[[package]] [[package]]
name = "ansicon" name = "ansicon"
version = "1.89.0" version = "1.89.0"
@ -41,6 +56,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies] [package.dependencies]
pyyaml = "*" pyyaml = "*"
[[package]]
name = "async-timeout"
version = "3.0.1"
description = "Timeout context manager for asyncio programs"
category = "main"
optional = false
python-versions = ">=3.5.3"
[[package]] [[package]]
name = "asyncssh" name = "asyncssh"
version = "2.7.0" version = "2.7.0"
@ -880,6 +903,7 @@ optional = false
python-versions = ">=3.6.1" python-versions = ">=3.6.1"
[package.dependencies] [package.dependencies]
python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""}
typing-extensions = ">=3.7.4.3" typing-extensions = ">=3.7.4.3"
[package.extras] [package.extras]
@ -1398,13 +1422,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = ">=3.8.1,<4.0" python-versions = ">=3.8.1,<4.0"
content-hash = "b99fec86745b99f5b0c132dadf90e07f8529aa751c8fbd582c36d6b82cd79dd3" content-hash = "34e21443d0af22b763bd715875da90ca519cde388af0e54b4d9a71180b14ca13"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
{file = "aiofiles-0.6.0-py3-none-any.whl", hash = "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27"}, {file = "aiofiles-0.6.0-py3-none-any.whl", hash = "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27"},
{file = "aiofiles-0.6.0.tar.gz", hash = "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"}, {file = "aiofiles-0.6.0.tar.gz", hash = "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"},
] ]
aioredis = [
{file = "aioredis-2.0.0-py3-none-any.whl", hash = "sha256:9921d68a3df5c5cdb0d5b49ad4fc88a4cfdd60c108325df4f0066e8410c55ffb"},
{file = "aioredis-2.0.0.tar.gz", hash = "sha256:3a2de4b614e6a5f8e104238924294dc4e811aefbe17ddf52c04a93cbf06e67db"},
]
ansicon = [ ansicon = [
{file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"},
{file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"},
@ -1420,6 +1448,10 @@ aredis = [
{file = "aspy.yaml-1.3.0-py2.py3-none-any.whl", hash = "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc"}, {file = "aspy.yaml-1.3.0-py2.py3-none-any.whl", hash = "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc"},
{file = "aspy.yaml-1.3.0.tar.gz", hash = "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"}, {file = "aspy.yaml-1.3.0.tar.gz", hash = "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"},
] ]
async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
]
asyncssh = [ asyncssh = [
{file = "asyncssh-2.7.0-py3-none-any.whl", hash = "sha256:ccc62a1b311c71d4bf8e4bc3ac141eb00ebb28b324e375aed1d0a03232893ca1"}, {file = "asyncssh-2.7.0-py3-none-any.whl", hash = "sha256:ccc62a1b311c71d4bf8e4bc3ac141eb00ebb28b324e375aed1d0a03232893ca1"},
{file = "asyncssh-2.7.0.tar.gz", hash = "sha256:185013d8e67747c3c0f01b72416b8bd78417da1df48c71f76da53c607ef541b6"}, {file = "asyncssh-2.7.0.tar.gz", hash = "sha256:185013d8e67747c3c0f01b72416b8bd78417da1df48c71f76da53c607ef541b6"},

View file

@ -47,7 +47,7 @@ netmiko = "^3.4.0"
paramiko = "^2.7.2" paramiko = "^2.7.2"
psutil = "^5.7.2" psutil = "^5.7.2"
py-cpuinfo = "^7.0.0" py-cpuinfo = "^7.0.0"
pydantic = "1.8.2" pydantic = {extras = ["dotenv"], version = "^1.8.2"}
python = ">=3.8.1,<4.0" python = ">=3.8.1,<4.0"
redis = "^3.5.3" redis = "^3.5.3"
scrapli = {version = "2021.07.30", extras = ["asyncssh"]} scrapli = {version = "2021.07.30", extras = ["asyncssh"]}
@ -55,6 +55,7 @@ typing-extensions = "^3.7.4"
uvicorn = {extras = ["standard"], version = "^0.13.4"} uvicorn = {extras = ["standard"], version = "^0.13.4"}
uvloop = "^0.14.0" uvloop = "^0.14.0"
xmltodict = "^0.12.0" xmltodict = "^0.12.0"
aioredis = "^2.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
bandit = "^1.6.2" bandit = "^1.6.2"
@ -97,7 +98,7 @@ reportMissingTypeStubs = true
check = {cmd = "task lint && task ui-lint", help = "Run all lint checks"} check = {cmd = "task lint && task ui-lint", help = "Run all lint checks"}
lint = {cmd = "flake8 hyperglass", help = "Run Flake8"} lint = {cmd = "flake8 hyperglass", help = "Run Flake8"}
sort = {cmd = "isort hyperglass", help = "Run iSort"} sort = {cmd = "isort hyperglass", help = "Run iSort"}
start = {cmd = "python3 -m hyperglass.console start", help = "Start hyperglass"} start = {cmd = "python3 -m hyperglass.main", help = "Start hyperglass"}
start-asgi = {cmd = "uvicorn hyperglass.api:app", help = "Start hyperglass via Uvicorn"} start-asgi = {cmd = "uvicorn hyperglass.api:app", help = "Start hyperglass via Uvicorn"}
test = {cmd = "pytest hyperglass", help = "Run hyperglass tests"} test = {cmd = "pytest hyperglass", help = "Run hyperglass tests"}
ui-build = {cmd = "python3 -m hyperglass.console build-ui", help = "Run a UI Build"} ui-build = {cmd = "python3 -m hyperglass.console build-ui", help = "Run a UI Build"}