diff --git a/hyperglass/configuration/models/branding.py b/hyperglass/configuration/models/branding.py index 9187dd0..3b41673 100644 --- a/hyperglass/configuration/models/branding.py +++ b/hyperglass/configuration/models/branding.py @@ -1,13 +1,17 @@ """Validate branding configuration variables.""" # Standard Library Imports +from pathlib import Path from typing import Optional # Third Party Imports +from pydantic import FilePath +from pydantic import HttpUrl from pydantic import StrictBool from pydantic import StrictInt from pydantic import StrictStr from pydantic import constr +from pydantic import root_validator from pydantic import validator from pydantic.color import Color @@ -65,11 +69,14 @@ class Branding(HyperglassModel): """Validation model for params.branding.help_menu.""" enable: StrictBool = True + file: Optional[FilePath] + title: StrictStr = "Help" class Logo(HyperglassModel): """Validation model for params.branding.logo.""" - logo_path: StrictStr = "ui/images/hyperglass-dark.png" + light: Optional[FilePath] + dark: Optional[FilePath] width: StrictInt = 384 height: Optional[StrictInt] favicons: StrictStr = "ui/images/favicons/" @@ -82,20 +89,72 @@ class Branding(HyperglassModel): chars.append("/") return "".join(chars) + @root_validator(pre=True) + def validate_logo_model(cls, values): + """Set default opengraph image location. + + Arguments: + values {dict} -- Unvalidated model + + Returns: + {dict} -- Modified model + """ + logo_light = values.get("light") + logo_dark = values.get("dark") + default_logo_light = ( + Path(__file__).parent.parent.parent + / "static/ui/images/hyperglass-light.png" + ) + default_logo_dark = ( + Path(__file__).parent.parent.parent + / "static/ui/images/hyperglass-dark.png" + ) + + # Use light logo as dark logo if dark logo is undefined. + if logo_light is not None and logo_dark is None: + values["dark"] = logo_light + + # Use dark logo as light logo if light logo is undefined. + if logo_dark is not None and logo_light is None: + values["light"] = logo_dark + + # Set default logo paths if logo is undefined. + if logo_light is None and logo_dark is None: + values["light"] = default_logo_light + values["dark"] = default_logo_dark + + return values + + @validator("light", "dark") + def validate_logos(cls, value): + """Convert file path to URL path. + + Arguments: + value {FilePath} -- Path to logo file. + + Returns: + {str} -- Formatted logo path + """ + return "".join(str(value).split("static")[1::]) + class Config: """Override pydantic config.""" fields = {"logo_path": "path"} - class PeeringDb(HyperglassModel): - """Validation model for params.branding.peering_db.""" + class ExternalLink(HyperglassModel): + """Validation model for params.branding.external_link.""" enable: StrictBool = True + title: StrictStr = "PeeringDB" + url: HttpUrl = "https://www.peeringdb.com/AS{primary_asn}" class Terms(HyperglassModel): """Validation model for params.branding.terms.""" enable: StrictBool = True + file: Optional[FilePath] + title: StrictStr = "Terms" class Text(HyperglassModel): """Validation model for params.branding.text.""" @@ -138,6 +197,6 @@ class Branding(HyperglassModel): font: Font = Font() help_menu: HelpMenu = HelpMenu() logo: Logo = Logo() - peering_db: PeeringDb = PeeringDb() + external_link: ExternalLink = ExternalLink() terms: Terms = Terms() text: Text = Text() diff --git a/hyperglass/configuration/models/general.py b/hyperglass/configuration/models/general.py index e3ace7c..ab3ac57 100644 --- a/hyperglass/configuration/models/general.py +++ b/hyperglass/configuration/models/general.py @@ -17,6 +17,7 @@ from pydantic import validator # Project Imports from hyperglass.configuration.models._utils import HyperglassModel +from hyperglass.configuration.models.opengraph import OpenGraph class General(HyperglassModel): @@ -25,6 +26,24 @@ class General(HyperglassModel): debug: StrictBool = False primary_asn: StrictStr = "65001" org_name: StrictStr = "The Company" + site_description: StrictStr = "{org_name} Network Looking Glass" + site_keywords: List[StrictStr] = [ + "hyperglass", + "looking glass", + "lg", + "peer", + "peering", + "ipv4", + "ipv6", + "transit", + "community", + "communities", + "bgp", + "routing", + "network", + "isp", + ] + opengraph: OpenGraph = OpenGraph() google_analytics: StrictStr = "" redis_host: StrictStr = "localhost" redis_port: StrictInt = 6379 @@ -34,6 +53,19 @@ class General(HyperglassModel): listen_port: StrictInt = 8001 log_file: Optional[FilePath] + @validator("site_description") + def validate_site_description(cls, value, values): + """Format the site descripion with the org_name field. + + Arguments: + value {str} -- site_description + values {str} -- Values before site_description + + Returns: + {str} -- Formatted description + """ + return value.format(org_name=values["org_name"]) + @validator("log_file") def validate_log_file(cls, value): """Set default logfile location if none is configured. diff --git a/hyperglass/configuration/models/opengraph.py b/hyperglass/configuration/models/opengraph.py new file mode 100644 index 0000000..55a0a19 --- /dev/null +++ b/hyperglass/configuration/models/opengraph.py @@ -0,0 +1,42 @@ +# Standard Library Imports +from pathlib import Path +from typing import Optional + +# Third Party Imports +import PIL.Image as PilImage +from pydantic import FilePath +from pydantic import StrictInt +from pydantic import validator + +# Project Imports +from hyperglass.configuration.models._utils import HyperglassModel + + +class OpenGraph(HyperglassModel): + """Validation model for params.general.opengraph.""" + + width: Optional[StrictInt] + height: Optional[StrictInt] + image: Optional[FilePath] + + @validator("image") + def validate_image(cls, value, values): + """Set default opengraph image location. + + Arguments: + value {FilePath} -- Path to opengraph image file. + + Returns: + {Path} -- Opengraph image file path object + """ + if value is None: + value = ( + Path(__file__).parent.parent.parent + / "static/ui/images/hyperglass-opengraph.png" + ) + with PilImage.open(value) as img: + width, height = img.size + values["width"] = width + values["height"] = height + + return "".join(str(value).split("static")[1::]) diff --git a/hyperglass/configuration/models/routers.py b/hyperglass/configuration/models/routers.py index 7c6b58c..fd38e47 100644 --- a/hyperglass/configuration/models/routers.py +++ b/hyperglass/configuration/models/routers.py @@ -149,7 +149,7 @@ class Router(HyperglassModel): log.debug( f'Field "display_name" for VRF "{vrf["name"]}" was not set. ' - f'Generated "display_name" {vrf["display_name"]}' + f"Generated '{vrf['display_name']}'" ) # Validate the non-default VRF against the standard @@ -167,6 +167,7 @@ class Routers(HyperglassModelExtra): vrfs: List[StrictStr] = [] display_vrfs: List[StrictStr] = [] routers: List[Router] = [] + networks: List[StrictStr] = [] @classmethod def _import(cls, input_params): @@ -183,7 +184,9 @@ class Routers(HyperglassModelExtra): {object} -- Validated routers object """ vrfs = set() + networks = set() display_vrfs = set() + vrf_objects = set() routers = Routers() routers.routers = [] routers.hostnames = [] @@ -224,9 +227,19 @@ class Routers(HyperglassModelExtra): "display_name": vrf.display_name, } + # Add the native VRF objects to a set (for automatic + # de-duping), but exlcude device-specific fields. + _copy_params = { + "deep": True, + "exclude": {"ipv4": {"source_address"}, "ipv6": {"source_address"}}, + } + vrf_objects.add(vrf.copy(**_copy_params)) + # Convert the de-duplicated sets to a standard list, add lists # as class attributes routers.vrfs = list(vrfs) routers.display_vrfs = list(display_vrfs) + routers.vrf_objects = list(vrf_objects) + routers.networks = list(networks) return routers diff --git a/hyperglass/configuration/models/vrfs.py b/hyperglass/configuration/models/vrfs.py index 1105711..d7d0545 100644 --- a/hyperglass/configuration/models/vrfs.py +++ b/hyperglass/configuration/models/vrfs.py @@ -10,6 +10,7 @@ from typing import List from typing import Optional # Third Party Imports +from pydantic import FilePath from pydantic import IPvAnyNetwork from pydantic import StrictStr from pydantic import constr @@ -19,6 +20,13 @@ from pydantic import validator from hyperglass.configuration.models._utils import HyperglassModel +class Info(HyperglassModel): + """Validation model for per-VRF help files.""" + + bgp_aspath: Optional[FilePath] + bgp_community: Optional[FilePath] + + class DeviceVrf4(HyperglassModel): """Validation model for IPv4 AFI definitions.""" @@ -36,8 +44,9 @@ class DeviceVrf6(HyperglassModel): class Vrf(HyperglassModel): """Validation model for per VRF/afi config in devices.yaml.""" - name: str - display_name: str + name: StrictStr + display_name: StrictStr + info: Info = Info() ipv4: Optional[DeviceVrf4] ipv6: Optional[DeviceVrf6] access_list: List[Dict[constr(regex=("allow|deny")), IPvAnyNetwork]] = [ @@ -71,12 +80,32 @@ class Vrf(HyperglassModel): li[action] = str(network) return value + def __hash__(self): + """Make VRF object hashable so the object can be deduplicated with set(). + + Returns: + {int} -- Hash of VRF name + """ + return hash((self.name,)) + + def __eq__(self, other): + """Make VRF object comparable so the object can be deduplicated with set(). + + Arguments: + other {object} -- Object to compare + + Returns: + {bool} -- True if comparison attributes are the same value + """ + return self.name == other.name + class DefaultVrf(HyperglassModel): """Validation model for default routing table VRF.""" name: StrictStr = "default" display_name: StrictStr = "Global" + info: Info = Info() access_list: List[Dict[constr(regex=("allow|deny")), IPvAnyNetwork]] = [ {"allow": IPv4Network("0.0.0.0/0")}, {"allow": IPv6Network("::/0")}, diff --git a/hyperglass/models/query.py b/hyperglass/models/query.py new file mode 100644 index 0000000..d3924e8 --- /dev/null +++ b/hyperglass/models/query.py @@ -0,0 +1,110 @@ +"""Input query validation model.""" + +# Standard Library Imports +import operator +from typing import Optional + +# Third Party Imports +from pydantic import BaseModel +from pydantic import StrictStr +from pydantic import validator + +# Project Imports +from hyperglass.configuration import devices +from hyperglass.configuration import params +from hyperglass.constants import Supported +from hyperglass.exceptions import InputInvalid + + +class Query(BaseModel): + """Validation model for input query parameters.""" + + query_location: StrictStr + query_type: StrictStr + query_vrf: Optional[StrictStr] + query_target: StrictStr + + @validator("query_location") + def validate_query_location(cls, value): + """Ensure query_location is defined. + + Arguments: + value {str} -- Unvalidated query_location + + Raises: + InputInvalid: Raised if query_location is not defined. + + Returns: + {str} -- Valid query_location + """ + if value not in devices.hostnames: + raise InputInvalid( + params.messages.invalid_field, + alert="warning", + input=value, + field=params.branding.text.query_location, + ) + return value + + @validator("query_type") + def validate_query_type(cls, value): + """Ensure query_type is supported. + + Arguments: + value {str} -- Unvalidated query_type + + Raises: + InputInvalid: Raised if query_type is not supported. + + Returns: + {str} -- Valid query_type + """ + if value not in Supported.query_types: + raise InputInvalid( + params.messages.invalid_field, + alert="warning", + input=value, + field=params.branding.text.query_type, + ) + else: + enabled = operator.attrgetter(f"{value}.enable")(params.features) + if not enabled: + raise InputInvalid( + params.messages.invalid_field, + alert="warning", + input=value, + field=params.branding.text.query_type, + ) + return value + + @validator("query_vrf", always=True, pre=True) + def validate_query_vrf(cls, value, values): + """Ensure query_vrf is defined. + + Arguments: + value {str} -- Unvalidated query_vrf + + Raises: + InputInvalid: Raised if query_vrf is not defined. + + Returns: + {str} -- Valid query_vrf + """ + device = getattr(devices, values["query_location"]) + default_vrf = "default" + if value is not None and value != default_vrf: + for vrf in device.vrfs: + if value == vrf.name: + value = vrf.name + elif value == vrf.display_name: + value = vrf.name + else: + raise InputInvalid( + params.messages.vrf_not_associated, + alert="warning", + vrf_name=value, + device_name=device.display_name, + ) + if value is None: + value = default_vrf + return value