Move UI Params into Pydantic model

This commit is contained in:
thatmattlove 2021-09-10 01:18:38 -07:00
parent 281895e259
commit 99c7489441
13 changed files with 240 additions and 153 deletions

View file

@ -18,6 +18,7 @@ from starlette.middleware.cors import CORSMiddleware
from hyperglass.log import log
from hyperglass.util import cpu_count
from hyperglass.constants import TRANSPORT_REST, __version__
from hyperglass.models.ui import UIParameters
from hyperglass.api.events import on_startup, on_shutdown
from hyperglass.api.routes import (
docs,
@ -25,8 +26,8 @@ from hyperglass.api.routes import (
query,
queries,
routers,
communities,
ui_props,
communities,
import_certificate,
)
from hyperglass.exceptions import HyperglassError
@ -246,6 +247,8 @@ app.add_api_route(
endpoint=ui_props,
methods=["GET", "OPTIONS"],
response_class=JSONResponse,
response_model=UIParameters,
response_model_by_alias=True,
)
# Enable certificate import route only if a device using

View file

@ -20,7 +20,7 @@ from hyperglass.api.tasks import process_headers, import_public_key
from hyperglass.constants import __version__
from hyperglass.exceptions import HyperglassError
from hyperglass.models.api import Query, EncodedRequest
from hyperglass.configuration import REDIS_CONFIG, params, devices, frontend_params
from hyperglass.configuration import REDIS_CONFIG, params, devices, ui_params
from hyperglass.execution.main import execute
# Local
@ -266,7 +266,7 @@ async def info():
async def ui_props():
"""Serve UI configration."""
return frontend_params
return ui_params
endpoints = [query, docs, routers, info, ui_props]

View file

@ -10,5 +10,17 @@ from .main import (
params,
devices,
commands,
frontend_params,
ui_params,
)
__all__ = (
"URL_DEV",
"URL_PROD",
"CONFIG_PATH",
"STATIC_PATH",
"REDIS_CONFIG",
"params",
"devices",
"commands",
"ui_params",
)

View file

@ -2,7 +2,6 @@
# Standard Library
import os
import json
from typing import Dict, List, Generator
from pathlib import Path
@ -20,6 +19,7 @@ from hyperglass.log import (
from hyperglass.util import set_app_path, set_cache_env, current_log_level
from hyperglass.defaults import CREDIT
from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__
from hyperglass.models.ui import UIParameters
from hyperglass.util.files import check_path
from hyperglass.exceptions.private import ConfigError, ConfigMissing
from hyperglass.models.config.params import Params
@ -204,35 +204,6 @@ except KeyError:
pass
def _build_networks() -> List[Dict]:
"""Build filtered JSON Structure of networks & devices for Jinja templates."""
networks = []
_networks = list(set({device.network.display_name for device in devices.objects}))
for _network in _networks:
network_def = {"display_name": _network, "locations": []}
for device in devices.objects:
if device.network.display_name == _network:
network_def["locations"].append(
{
"_id": device._id,
"name": device.name,
"network": device.network.display_name,
"directives": [c.frontend(params) for c in device.commands],
}
)
networks.append(network_def)
if not networks:
raise ConfigError(message="Unable to build network to device mapping")
return networks
content_params = json.loads(
params.json(include={"primary_asn", "org_name", "site_title", "site_description"})
)
content_greeting = get_markdown(
config_path=params.web.greeting,
default="",
@ -242,38 +213,17 @@ content_greeting = get_markdown(
content_credit = CREDIT.format(version=__version__)
networks = _build_networks()
_ui_params = params.frontend()
_ui_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix
_ui_params["web"]["logo"]["dark_format"] = params.web.logo.dark.suffix
_include_fields = {
"cache": {"show_text", "timeout"},
"debug": ...,
"developer_mode": ...,
"primary_asn": ...,
"request_timeout": ...,
"org_name": ...,
"google_analytics": ...,
"site_title": ...,
"site_description": ...,
"site_keywords": ...,
"web": ...,
"messages": ...,
}
_frontend_params = params.dict(include=_include_fields)
_frontend_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix
_frontend_params["web"]["logo"]["dark_format"] = params.web.logo.dark.suffix
_frontend_params.update(
{
"hyperglass_version": __version__,
"queries": {**params.queries.map, "list": params.queries.list},
"networks": networks,
"parsed_data_fields": PARSED_RESPONSE_FIELDS,
"content": {"credit": content_credit, "greeting": content_greeting},
}
ui_params = UIParameters(
**_ui_params,
version=__version__,
networks=devices.networks(params),
parsed_data_fields=PARSED_RESPONSE_FIELDS,
content={"credit": content_credit, "greeting": content_greeting},
)
frontend_params = _frontend_params
URL_DEV = f"http://localhost:{str(params.listen_port)}/"
URL_PROD = "/api/"

View file

@ -41,7 +41,7 @@ from .configuration import (
CONFIG_PATH,
REDIS_CONFIG,
params,
frontend_params,
ui_params,
)
from .util.frontend import build_frontend
@ -85,7 +85,7 @@ async def build_ui() -> bool:
dev_mode=params.developer_mode,
dev_url=URL_DEV,
prod_url=URL_PROD,
params=frontend_params,
params=ui_params,
app_path=CONFIG_PATH,
)
return True

View file

@ -2,7 +2,6 @@
# Standard Library
import re
import json
from typing import Dict, List, Union, Literal, Optional
from ipaddress import IPv4Network, IPv6Network, ip_network
@ -267,20 +266,10 @@ class Directive(HyperglassModel):
}
if self.info is not None:
content_params = json.loads(
params.json(
include={
"primary_asn",
"org_name",
"site_title",
"site_description",
}
)
)
with self.info.open() as md:
value["info"] = {
"enable": True,
"params": content_params,
"params": params.content_params(),
"content": md.read(),
}

View file

@ -10,15 +10,20 @@ from pydantic import SecretStr, StrictInt, StrictStr, StrictBool, IPvAnyAddress
from ..main import HyperglassModel
class Cache(HyperglassModel):
class CachePublic(HyperglassModel):
"""Public cache parameters."""
timeout: StrictInt = 120
show_text: StrictBool = True
class Cache(CachePublic):
"""Validation model for params.cache."""
host: Union[IPvAnyAddress, StrictStr] = "localhost"
port: StrictInt = 6379
database: StrictInt = 1
password: Optional[SecretStr]
timeout: StrictInt = 120
show_text: StrictBool = True
class Config:
"""Pydantic model configuration."""

View file

@ -28,6 +28,7 @@ from hyperglass.models.commands.generic import Directive
from .ssl import Ssl
from ..main import HyperglassModel, HyperglassModelExtra
from .proxy import Proxy
from .params import Params
from ..fields import SupportedDriver
from .network import Network
from .credential import Credential
@ -274,3 +275,23 @@ class Devices(HyperglassModelExtra):
return device
raise AttributeError(f"No device named '{accessor}'")
def networks(self, params: Params) -> List[Dict[str, Any]]:
"""Group devices by network."""
names = {device.network.display_name for device in self.objects}
return [
{
"display_name": name,
"locations": [
{
"id": device._id,
"name": device.name,
"network": device.network.display_name,
"directives": [c.frontend(params) for c in device.commands],
}
for device in self.objects
if device.network.display_name in names
],
}
for name in names
]

View file

@ -1,19 +1,11 @@
"""Configuration validation entry point."""
# Standard Library
from typing import List, Union, Optional
from typing import Any, Dict, List, Union, Literal, Optional
from ipaddress import ip_address
# Third Party
from pydantic import (
Field,
StrictInt,
StrictStr,
StrictBool,
IPvAnyAddress,
constr,
validator,
)
from pydantic import Field, StrictInt, StrictStr, StrictBool, IPvAnyAddress, validator
# Local
from .web import Web
@ -26,13 +18,12 @@ from .queries import Queries
from .messages import Messages
from .structured import Structured
Localhost = constr(regex=r"localhost")
Localhost = Literal["localhost"]
class Params(HyperglassModel):
"""Validation model for all configuration variables."""
class ParamsPublic(HyperglassModel):
"""Public configuration parameters."""
# Top Level Params
debug: StrictBool = Field(
False,
title="Debug",
@ -43,10 +34,10 @@ class Params(HyperglassModel):
title="Developer Mode",
description='Enable developer mode. If enabled, the hyperglass backend (Python) and frontend (React/Javascript) applications are "unlinked", so that React tools can be used for front end development. A `<Debugger />` convenience component is also displayed in the UI for easier UI development.',
)
fake_output: StrictBool = Field(
False,
title="Fake Output",
description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.",
request_timeout: StrictInt = Field(
90,
title="Request Timeout",
description="Global timeout in seconds for all requests. The frontend application (UI) uses this field's exact value when submitting queries. The backend application uses this field's value, minus one second, for its own timeout handling. This is to ensure a contextual timeout error is presented to the end user in the event of a backend application timeout.",
)
primary_asn: Union[StrictInt, StrictStr] = Field(
"65001",
@ -58,6 +49,7 @@ class Params(HyperglassModel):
title="Organization Name",
description="Your organization's name. This field is used in the UI & API documentation to set fields such as `<meta/>` HTML tags for SEO and the terms & conditions footer component.",
)
google_analytics: Optional[StrictStr]
site_title: StrictStr = Field(
"hyperglass",
title="Site Title",
@ -90,10 +82,17 @@ class Params(HyperglassModel):
title="Site Keywords",
description='Keywords pertaining to your hyperglass site. This field is used to generate `<meta name="keywords"/>` HTML tags, which helps tremendously with SEO.',
)
request_timeout: StrictInt = Field(
90,
title="Request Timeout",
description="Global timeout in seconds for all requests. The frontend application (UI) uses this field's exact value when submitting queries. The backend application uses this field's value, minus one second, for its own timeout handling. This is to ensure a contextual timeout error is presented to the end user in the event of a backend application timeout.",
class Params(ParamsPublic, HyperglassModel):
"""Validation model for all configuration variables."""
# Top Level Params
fake_output: StrictBool = Field(
False,
title="Fake Output",
description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.",
)
listen_address: Optional[Union[IPvAnyAddress, Localhost]] = Field(
None,
@ -115,7 +114,6 @@ class Params(HyperglassModel):
title="Netmiko Delay Factor",
description="Override the netmiko global delay factor.",
)
google_analytics: Optional[StrictStr]
# Sub Level Params
cache: Cache = Cache()
@ -183,3 +181,29 @@ class Params(HyperglassModel):
if not isinstance(value, str):
value = str(value)
return value
def content_params(self) -> Dict[str, Any]:
"""Export content-specific parameters."""
return self.dict(
include={"primary_asn", "org_name", "site_title", "site_description"}
)
def frontend(self) -> Dict[str, Any]:
"""Export UI-specific parameters."""
return self.dict(
include={
"cache": {"show_text", "timeout"},
"debug": ...,
"developer_mode": ...,
"primary_asn": ...,
"request_timeout": ...,
"org_name": ...,
"google_analytics": ...,
"site_title": ...,
"site_description": ...,
"site_keywords": ...,
"web": ...,
"messages": ...,
}
)

View file

@ -125,6 +125,13 @@ class Logo(HyperglassModel):
height: Optional[Union[StrictInt, Percentage]]
class LogoPublic(Logo):
"""Public logo configuration."""
light_format: StrictStr
dark_format: StrictStr
class Text(HyperglassModel):
"""Validation model for params.branding.text."""
@ -258,3 +265,9 @@ class Web(HyperglassModel):
opengraph: OpenGraph = OpenGraph()
text: Text = Text()
theme: Theme = Theme()
class WebPublic(Web):
"""Public web configuration."""
logo: LogoPublic

View file

@ -2,12 +2,13 @@
# Standard Library
import re
from typing import Type, TypeVar
# Third Party
from pydantic import HttpUrl, BaseModel
_WEBHOOK_TITLE = "hyperglass received a valid query with the following data"
_ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon.png"
# Project
from hyperglass.util import snake_to_camel
def clean_name(_name: str) -> str:
@ -22,11 +23,14 @@ def clean_name(_name: str) -> str:
return _scrubbed.lower()
AsUIModel = TypeVar("AsUIModel", bound="BaseModel")
class HyperglassModel(BaseModel):
"""Base model for all hyperglass configuration models."""
class Config:
"""Default Pydantic configuration.
"""Pydantic model configuration.
See https://pydantic-docs.helpmanual.io/usage/model_config
"""
@ -38,40 +42,28 @@ class HyperglassModel(BaseModel):
json_encoders = {HttpUrl: lambda v: str(v)}
def export_json(self, *args, **kwargs):
"""Return instance as JSON.
"""Return instance as JSON."""
Returns:
{str} -- Stringified JSON.
"""
export_kwargs = {"by_alias": True, "exclude_unset": False}
export_kwargs = {
"by_alias": True,
"exclude_unset": False,
**kwargs,
}
for key in export_kwargs.keys():
export_kwargs.pop(key, None)
return self.json(*args, **export_kwargs)
return self.json(*args, **export_kwargs, **kwargs)
def export_dict(self, *args, **kwargs):
"""Return instance as dictionary.
"""Return instance as dictionary."""
Returns:
{dict} -- Python dictionary.
"""
export_kwargs = {
"by_alias": True,
"exclude_unset": False,
**kwargs,
}
export_kwargs = {"by_alias": True, "exclude_unset": False}
return self.dict(*args, **export_kwargs)
for key in export_kwargs.keys():
export_kwargs.pop(key, None)
return self.dict(*args, **export_kwargs, **kwargs)
def export_yaml(self, *args, **kwargs):
"""Return instance as YAML.
"""Return instance as YAML."""
Returns:
{str} -- Stringified YAML.
"""
# Standard Library
import json
@ -91,9 +83,22 @@ class HyperglassModel(BaseModel):
class HyperglassModelExtra(HyperglassModel):
"""Model for hyperglass configuration models with dynamic fields."""
pass
class Config:
"""Default pydantic configuration."""
"""Pydantic model configuration."""
extra = "allow"
class HyperglassUIModel(HyperglassModel):
"""Base class for UI configuration parameters."""
class Config:
"""Pydantic model configuration."""
alias_generator = snake_to_camel
allow_population_by_field_name = True
def as_ui_model(name: str, model: Type[AsUIModel]) -> Type[AsUIModel]:
"""Override a model's configuration to confirm to a UI model."""
return type(name, (model, HyperglassUIModel), {})

76
hyperglass/models/ui.py Normal file
View file

@ -0,0 +1,76 @@
"""UI Configuration models."""
# Standard Library
from typing import Any, Dict, List, Tuple, Union, Literal, Optional
# Third Party
from pydantic import StrictStr, StrictBool
# Local
from .main import HyperglassUIModel, as_ui_model
from .config.web import WebPublic
from .config.cache import CachePublic
from .config.params import ParamsPublic
from .config.messages import Messages
Alignment = Union[Literal["left"], Literal["center"], Literal["right"], None]
StructuredDataField = Tuple[str, str, Alignment]
CacheUI = as_ui_model("CacheUI", CachePublic)
WebUI = as_ui_model("WebUI", WebPublic)
MessagesUI = as_ui_model("MessagesUI", Messages)
class UIDirectiveInfo(HyperglassUIModel):
"""UI: Directive Info."""
enable: StrictBool
params: Dict[str, str]
content: StrictStr
class UIDirective(HyperglassUIModel):
"""UI: Directive."""
id: StrictStr
name: StrictStr
field_type: StrictStr
groups: List[StrictStr]
description: StrictStr
info: Optional[UIDirectiveInfo] = None
options: Optional[List[Dict[str, Any]]]
class UILocation(HyperglassUIModel):
"""UI: Location (Device)."""
id: StrictStr
name: StrictStr
network: StrictStr
directives: List[UIDirective] = []
class UINetwork(HyperglassUIModel):
"""UI: Network."""
display_name: StrictStr
locations: List[UILocation] = []
class UIContent(HyperglassUIModel):
"""UI: Content."""
credit: StrictStr
greeting: StrictStr
class UIParameters(HyperglassUIModel, ParamsPublic):
"""UI Configuration Parameters."""
cache: CacheUI
web: WebUI
messages: MessagesUI
version: StrictStr
networks: List[UINetwork] = []
parsed_data_fields: Tuple[StructuredDataField, ...]
content: UIContent

View file

@ -12,6 +12,7 @@ from pathlib import Path
# Project
from hyperglass.log import log
from hyperglass.models.ui import UIParameters
# Local
from .files import copyfiles, check_path
@ -221,7 +222,7 @@ def generate_opengraph(
return True
def migrate_images(app_path: Path, params: dict):
def migrate_images(app_path: Path, params: UIParameters):
"""Migrate images from source code to install directory."""
images_dir = app_path / "static" / "images"
favicon_dir = images_dir / "favicons"
@ -230,7 +231,7 @@ def migrate_images(app_path: Path, params: dict):
dst_files = ()
for image in ("light", "dark", "favicon"):
src = Path(params["web"]["logo"][image])
src: Path = getattr(params.web.logo, image)
dst = images_dir / f"{image + src.suffix}"
src_files += (src,)
dst_files += (dst,)
@ -241,7 +242,7 @@ async def build_frontend( # noqa: C901
dev_mode: bool,
dev_url: str,
prod_url: str,
params: Dict,
params: UIParameters,
app_path: Path,
force: bool = False,
timeout: int = 180,
@ -259,18 +260,6 @@ async def build_frontend( # noqa: C901
After the build is successful, the temporary file is automatically
closed during garbage collection.
Arguments:
dev_mode {bool} -- Development Mode
dev_url {str} -- Development Mode URL
prod_url {str} -- Production Mode URL
params {dict} -- Frontend Config paramters
Raises:
RuntimeError: Raised if errors occur during build process.
Returns:
{bool} -- True if successful
"""
# Standard Library
import hashlib
@ -326,7 +315,7 @@ async def build_frontend( # noqa: C901
if not favicon_dir.exists():
favicon_dir.mkdir()
async with Favicons(
source=params["web"]["logo"]["favicon"],
source=params.web.logo.favicon,
output_directory=favicon_dir,
base_url="/images/favicons/",
) as favicons:
@ -334,7 +323,7 @@ async def build_frontend( # noqa: C901
log.debug("Generated {} favicons", favicons.completed)
env_vars["hyperglass"].update({"favicons": favicons.formats()})
build_data = {
"params": params,
"params": params.export_dict(),
"version": __version__,
"package_json": package_json,
}
@ -379,11 +368,11 @@ async def build_frontend( # noqa: C901
migrate_images(app_path, params)
generate_opengraph(
Path(params["web"]["opengraph"]["image"]),
params.web.opengraph.image,
1200,
630,
images_dir,
params["web"]["theme"]["colors"]["black"],
params.web.theme.colors.black,
)
except Exception as err: