diff --git a/hyperglass/configuration/main.py b/hyperglass/configuration/main.py index 5710462..029b78e 100644 --- a/hyperglass/configuration/main.py +++ b/hyperglass/configuration/main.py @@ -4,7 +4,7 @@ import os import copy import json -from typing import Dict, List +from typing import Dict, List, Sequence, Generator from pathlib import Path # Third Party @@ -25,9 +25,11 @@ from hyperglass.constants import ( ) from hyperglass.exceptions import ConfigError, ConfigMissing from hyperglass.util.files import check_path -from hyperglass.models.commands import Commands + +# from hyperglass.models.commands import Commands +from hyperglass.models.commands.generic import Directive from hyperglass.models.config.params import Params -from hyperglass.models.config.devices import Devices +from hyperglass.models.config.devices import Devices, Device from hyperglass.configuration.defaults import ( CREDIT, DEFAULT_HELP, @@ -116,6 +118,29 @@ def _config_optional(config_path: Path) -> Dict: return config +def _get_commands(data: Dict) -> Sequence[Directive]: + commands = [] + for name, command in data.items(): + commands.append(Directive(id=name, **command)) + return commands + + +def _device_commands( + device: Dict, directives: Sequence[Directive] +) -> Generator[Directive, None, None]: + device_commands = device.get("commands", []) + for directive in directives: + if directive.id in device_commands: + yield directive + + +def _get_devices(data: Sequence[Dict], directives: Sequence[Directive]) -> Devices: + for device in data: + device_commands = list(_device_commands(device, directives)) + device["commands"] = device_commands + return Devices(data) + + user_config = _config_optional(CONFIG_MAIN) # Read raw debug value from config to enable debugging quickly. @@ -136,15 +161,16 @@ elif not params.debug and log_level == "debug": # Map imported user commands to expected schema. _user_commands = _config_optional(CONFIG_COMMANDS) log.debug("Unvalidated commands from {}: {}", CONFIG_COMMANDS, _user_commands) -commands = validate_config(config=_user_commands, importer=Commands.import_params) +commands = _get_commands(_user_commands) # Map imported user devices to expected schema. _user_devices = _config_required(CONFIG_DEVICES) log.debug("Unvalidated devices from {}: {}", CONFIG_DEVICES, _user_devices) -devices = validate_config(config=_user_devices.get("routers", []), importer=Devices) +# devices = validate_config(config=_user_devices.get("routers", []), importer=Devices) +devices = _get_devices(_user_devices.get("routers", []), commands) # Validate commands are both supported and properly mapped. -validate_nos_commands(devices.all_nos, commands) +# validate_nos_commands(devices.all_nos, commands) # Set cache configurations to environment variables, so they can be # used without importing this module (Gunicorn, etc). @@ -189,65 +215,6 @@ except KeyError: pass -def _build_frontend_devices(): - """Build filtered JSON structure of devices for frontend. - - Schema: - { - "device.name": { - "display_name": "device.display_name", - "vrfs": [ - "Global", - "vrf.display_name" - ] - } - } - - Raises: - ConfigError: Raised if parsing/building error occurs. - - Returns: - {dict} -- Frontend devices - """ - frontend_dict = {} - for device in devices.objects: - if device.name in frontend_dict: - frontend_dict[device.name].update( - { - "network": device.network.display_name, - "display_name": device.display_name, - "vrfs": [ - { - "id": vrf.name, - "display_name": vrf.display_name, - "default": vrf.default, - "ipv4": True if vrf.ipv4 else False, # noqa: IF100 - "ipv6": True if vrf.ipv6 else False, # noqa: IF100 - } - for vrf in device.vrfs - ], - } - ) - elif device.name not in frontend_dict: - frontend_dict[device.name] = { - "network": device.network.display_name, - "display_name": device.display_name, - "vrfs": [ - { - "id": vrf.name, - "display_name": vrf.display_name, - "default": vrf.default, - "ipv4": True if vrf.ipv4 else False, # noqa: IF100 - "ipv6": True if vrf.ipv6 else False, # noqa: IF100 - } - for vrf in device.vrfs - ], - } - if not frontend_dict: - raise ConfigError(error_msg="Unable to build network to device mapping") - return frontend_dict - - def _build_networks() -> List[Dict]: """Build filtered JSON Structure of networks & devices for Jinja templates.""" networks = [] @@ -262,6 +229,7 @@ def _build_networks() -> List[Dict]: "_id": device._id, "name": device.name, "network": device.network.display_name, + "directives": [c.frontend(params) for c in device.commands], "vrfs": [ { "_id": vrf._id, @@ -346,7 +314,7 @@ content_terms = get_markdown( content_credit = CREDIT.format(version=__version__) networks = _build_networks() -frontend_devices = _build_frontend_devices() + _include_fields = { "cache": {"show_text", "timeout"}, "debug": ..., diff --git a/hyperglass/models/commands/generic.py b/hyperglass/models/commands/generic.py new file mode 100644 index 0000000..6a19d18 --- /dev/null +++ b/hyperglass/models/commands/generic.py @@ -0,0 +1,133 @@ +import json +from ipaddress import IPv4Network, IPv6Network +from typing import Optional, Sequence, Union, Dict +from typing_extensions import Literal +from pydantic import StrictStr, PrivateAttr, conint, validator, FilePath +from ..main import HyperglassModel +from ..config.params import Params +from hyperglass.configuration.markdown import get_markdown + +IPv4PrefixLength = conint(ge=0, le=32) +IPv6PrefixLength = conint(ge=0, le=128) + + +class Policy(HyperglassModel): + network: Union[IPv4Network, IPv6Network] + action: Literal["permit", "deny"] + + @validator("ge", check_fields=False) + def validate_ge(cls, value: int, values: Dict) -> int: + """Ensure ge is at least the size of the input prefix.""" + + network_len = values["network"].prefixlen + + if network_len > value: + value = network_len + + return value + + +class Policy4(Policy): + ge: IPv4PrefixLength = 0 + le: IPv4PrefixLength = 32 + + +class Policy6(Policy): + ge: IPv6PrefixLength = 0 + le: IPv6PrefixLength = 128 + + +class Input(HyperglassModel): + _type: PrivateAttr + description: StrictStr + + def is_select(self) -> bool: + return self._type == "select" + + def is_text(self) -> bool: + return self._type == "text" + + def is_ip(self) -> bool: + return self._type == "ip" + + +class Text(Input): + _type: PrivateAttr = "text" + validation: Optional[StrictStr] + + +class IPInput(Input): + _type: PrivateAttr = "ip" + validation: Union[Policy4, Policy6] + + +class Option(HyperglassModel): + name: Optional[StrictStr] + value: StrictStr + + +class Select(Input): + _type: PrivateAttr = "select" + options: Sequence[Option] + + +class Directive(HyperglassModel): + id: StrictStr + name: StrictStr + command: Union[StrictStr, Sequence[StrictStr]] + field: Union[Text, Select, IPInput, None] + info: Optional[FilePath] + attrs: Dict = {} + groups: Sequence[ + StrictStr + ] = [] # TODO: Flesh this out. Replace VRFs, but use same logic in React to filter available commands for multi-device queries. + + @validator("command") + def validate_command(cls, value: Union[str, Sequence[str]]) -> Sequence[str]: + if isinstance(value, str): + return [value] + return value + + def get_commands(self, target: str) -> Sequence[str]: + return [s.format(target=target, **self.attrs) for s in self.command] + + @property + def field_type(self) -> Literal["text", "select", None]: + if self.field.is_select(): + return "select" + elif self.field.is_text() or self.field.is_ip(): + return "text" + return None + + def frontend(self, params: Params) -> Dict: + + value = { + "name": self.name, + "field_type": self.field_type, + "groups": self.groups, + "description": self.field.description, + "info": None, + } + + 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, + "content": md.read(), + } + + if self.field_type == "select": + value["options"]: [o.export_dict() for o in self.field.options] + + return value diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index a353610..65a1ac0 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -3,7 +3,7 @@ # Standard Library import os import re -from typing import Any, Dict, List, Tuple, Union, Optional +from typing import Any, Dict, List, Tuple, Union, Optional, Sequence from pathlib import Path from ipaddress import IPv4Address, IPv6Address @@ -22,6 +22,8 @@ from hyperglass.log import log from hyperglass.util import get_driver, validate_nos, resolve_hostname from hyperglass.constants import SCRAPE_HELPERS, SUPPORTED_STRUCTURED_OUTPUT from hyperglass.exceptions import ConfigError, UnsupportedDevice +from hyperglass.models.commands.generic import Directive + # Local from .ssl import Ssl @@ -89,10 +91,8 @@ class Device(HyperglassModel): port: StrictInt = 22 ssl: Optional[Ssl] nos: StrictStr - commands: Optional[StrictStr] + commands: Sequence[Directive] vrfs: List[Vrf] = [_default_vrf] - display_vrfs: List[StrictStr] = [] - vrf_names: List[StrictStr] = [] structured_output: Optional[StrictBool] driver: Optional[SupportedDriver] @@ -172,7 +172,7 @@ class Device(HyperglassModel): return value @root_validator(pre=True) - def validate_nos_commands(cls, values: "Device") -> "Device": + def validate_nos_commands(cls, values: Dict) -> Dict: """Validate & rewrite NOS, set default commands.""" nos = values.get("nos", "") @@ -205,7 +205,7 @@ class Device(HyperglassModel): if "_telnet" in inferred: inferred = inferred.replace("_telnet", "") - values["commands"] = inferred + values["commands"] = [inferred] return values @@ -288,7 +288,6 @@ class Devices(HyperglassModelExtra): _ids: List[StrictStr] = [] hostnames: List[StrictStr] = [] vrfs: List[StrictStr] = [] - display_vrfs: List[StrictStr] = [] vrf_objects: List[Vrf] = [] objects: List[Device] = [] all_nos: List[StrictStr] = [] @@ -300,15 +299,8 @@ class Devices(HyperglassModelExtra): Remove unsupported characters from device names, dynamically set attributes for the devices class. Builds lists of common attributes for easy access in other modules. - - Arguments: - input_params {dict} -- Unvalidated router definitions - - Returns: - {object} -- Validated routers object """ vrfs = set() - display_vrfs = set() vrf_objects = set() all_nos = set() objects = set() @@ -328,19 +320,13 @@ class Devices(HyperglassModelExtra): hostnames.add(device.name) _ids.add(device._id) objects.add(device) - all_nos.add(device.commands) + all_nos.add(device.nos) for vrf in device.vrfs: # For each configured router VRF, add its name and # display_name to a class set (for automatic de-duping). vrfs.add(vrf.name) - display_vrfs.add(vrf.display_name) - - # Also add the names to a router-level list so each - # router's VRFs and display VRFs can be easily accessed. - device.display_vrfs.append(vrf.display_name) - device.vrf_names.append(vrf.name) # Add a 'default_vrf' attribute to the devices class # which contains the configured default VRF display name. @@ -367,7 +353,6 @@ class Devices(HyperglassModelExtra): init_kwargs["hostnames"] = list(hostnames) init_kwargs["all_nos"] = list(all_nos) init_kwargs["vrfs"] = list(vrfs) - init_kwargs["display_vrfs"] = list(vrfs) init_kwargs["vrf_objects"] = list(vrf_objects) init_kwargs["objects"] = sorted(objects, key=lambda x: x.name) diff --git a/hyperglass/ui/components/debugger.tsx b/hyperglass/ui/components/debugger.tsx index dd4b732..dcc3068 100644 --- a/hyperglass/ui/components/debugger.tsx +++ b/hyperglass/ui/components/debugger.tsx @@ -23,12 +23,12 @@ interface TViewer extends Pick { const Viewer: React.FC = (props: TViewer) => { const { title, isOpen, onClose, children } = props; - const bg = useColorValue('white', 'black'); + const bg = useColorValue('white', 'blackSolid.700'); const color = useColorValue('black', 'white'); return ( - + {title} diff --git a/hyperglass/ui/components/form/field.tsx b/hyperglass/ui/components/form/field.tsx index c3c6845..b71244c 100644 --- a/hyperglass/ui/components/form/field.tsx +++ b/hyperglass/ui/components/form/field.tsx @@ -24,13 +24,11 @@ export const FormField: React.FC = (props: TField) => { return ( ({ value: q._id, label: q.display_name })); +// } + +type QueryGroups = Record; + +function buildOptions(networks: TNetwork[]): QueryGroups { + const options = {} as QueryGroups; + for (const net of networks) { + for (const loc of net.locations) { + for (const directive of loc.directives) { + for (const group of directive.groups) { + if (Object.keys(options).includes(group)) { + options[group] = [...options[group], loc.name]; + } else { + options[group] = [loc.name]; + } + } + } + } + } + return options; +} + +export const QueryGroup: React.FC = (props: TQueryGroup) => { + const { groups, onChange, label } = props; + const { selections, availableGroups, queryLocation, queryGroup } = useLGState(); + const { exportState } = useLGMethods(); + + // const groups = useMemo(() => buildOptions(networks), []); + // const options = useMemo( + // () => Object.keys(groups).map(key => ({ label: key, value: key })), + // [groups], + // ); + // const options = useMemo(() => groups.map(g => ({ label: g, value: g })), [ + // groups, + // ]); + const options = useMemo( + () => availableGroups.map(g => ({ label: g.value, value: g.value })), + [availableGroups.length, queryLocation.length], + ); + + function handleChange(e: TSelectOption | TSelectOption[]): void { + let value = ''; + if (!Array.isArray(e) && e !== null) { + selections.queryGroup.set(e); + value = e.value; + } else { + selections.queryGroup.set(null); + } + onChange({ field: 'query_group', value }); + } + + return ( +