diff --git a/docs/docs/adding-devices.mdx b/docs/docs/adding-devices.mdx
index 12f4b2f..ed19ea1 100644
--- a/docs/docs/adding-devices.mdx
+++ b/docs/docs/adding-devices.mdx
@@ -49,9 +49,9 @@ routers:
| `nos` | String | Network Operating System. Must be a supported platform. |
| `structured_output` | Boolean | Disabled output parsing to structured data. |
| `credential` | | [Device Credential Configuration](#credential) |
-| `vrfs` | | [Device VRF Configuration](#vrf) |
+| `vrfs` | | [Device VRF Configuration](#vrfs) |
| `proxy` | | [SSH Proxy Configuration](#proxy) |
-| `ssl` | | [SSL Configuration](#ss;) for devices using [hyperglass-agent](https://github.com/checktheroads/hyperglass-agent). |
+| `ssl` | | [SSL Configuration](#ssl) for devices using [hyperglass-agent](https://github.com/checktheroads/hyperglass-agent). |
### `proxy`
diff --git a/docs/docs/ui/configuration.mdx b/docs/docs/ui/configuration.mdx
index 875df9e..fb36b69 100644
--- a/docs/docs/ui/configuration.mdx
+++ b/docs/docs/ui/configuration.mdx
@@ -23,10 +23,10 @@ $ hyperglass build-ui
✅ Completed UI build in production mode
```
-Or with the `--build-ui` CLI flag on startup:
+Or with the `--build` CLI flag on startup:
```shell-session
-$ hyperglass start --build-ui
+$ hyperglass start --build
```
The UI build will run automatically any time the hyperglass configuration or version changes.
diff --git a/hyperglass/configuration/main.py b/hyperglass/configuration/main.py
index e71292d..1ee5542 100644
--- a/hyperglass/configuration/main.py
+++ b/hyperglass/configuration/main.py
@@ -4,12 +4,11 @@
import os
import copy
import json
-from typing import Dict, List, Union, Callable
+from typing import Dict
from pathlib import Path
# Third Party
import yaml
-from pydantic import ValidationError
# Project
from hyperglass.log import (
@@ -19,15 +18,12 @@ from hyperglass.log import (
enable_syslog_logging,
)
from hyperglass.util import check_path, set_app_path, set_cache_env, current_log_level
-from hyperglass.models import HyperglassModel
from hyperglass.constants import (
- TRANSPORT_REST,
SUPPORTED_QUERY_TYPES,
PARSED_RESPONSE_FIELDS,
- SUPPORTED_STRUCTURED_OUTPUT,
__version__,
)
-from hyperglass.exceptions import ConfigError, ConfigInvalid, ConfigMissing
+from hyperglass.exceptions import ConfigError, ConfigMissing
from hyperglass.models.commands import Commands
from hyperglass.models.config.params import Params
from hyperglass.models.config.devices import Devices
@@ -40,6 +36,7 @@ from hyperglass.configuration.defaults import (
# Local
from .markdown import get_markdown
+from .validation import validate_config, validate_nos_commands
set_app_path(required=True)
@@ -55,18 +52,8 @@ CONFIG_FILES = (
)
-def _check_config_files(directory):
- """Verify config files exist and are readable.
-
- Arguments:
- directory {Path} -- Config directory Path object
-
- Raises:
- ConfigMissing: Raised if a required config file does not pass checks.
-
- Returns:
- {tuple} -- main config, devices config, commands config
- """
+def _check_config_files(directory: Path):
+ """Verify config files exist and are readable."""
files = ()
for file in CONFIG_FILES:
file_name, required = file
@@ -124,42 +111,6 @@ def _config_optional(config_path: Path) -> Dict:
return config
-def _validate_nos_commands(all_nos, commands):
- nos_with_commands = commands.dict().keys()
-
- for nos in all_nos:
- valid = False
- if nos in SUPPORTED_STRUCTURED_OUTPUT:
- valid = True
- elif nos in TRANSPORT_REST:
- valid = True
- elif nos in nos_with_commands:
- valid = True
-
- if not valid:
- raise ConfigError(
- '"{nos}" is used on a device, '
- + 'but no command profile for "{nos}" is defined.',
- nos=nos,
- )
-
- return True
-
-
-def _validate_config(config: Union[Dict, List], importer: Callable) -> HyperglassModel:
- validated = None
- try:
- if isinstance(config, Dict):
- validated = importer(**config)
- elif isinstance(config, List):
- validated = importer(config)
- except ValidationError as err:
- log.error(str(err))
- raise ConfigInvalid(err.errors()) from None
-
- return validated
-
-
user_config = _config_optional(CONFIG_MAIN)
# Read raw debug value from config to enable debugging quickly.
@@ -167,7 +118,7 @@ set_log_level(logger=log, debug=user_config.get("debug", True))
# Map imported user configuration to expected schema.
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)
@@ -180,15 +131,15 @@ 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 = validate_config(config=_user_commands, importer=Commands.import_params)
# 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)
# 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).
diff --git a/hyperglass/configuration/validation.py b/hyperglass/configuration/validation.py
new file mode 100644
index 0000000..900cb93
--- /dev/null
+++ b/hyperglass/configuration/validation.py
@@ -0,0 +1,50 @@
+"""Post-Validation Validation.
+
+Some validations need to occur across multiple config files.
+"""
+# Standard Library
+from typing import Dict, List, Union, Callable
+
+# Third Party
+from pydantic import ValidationError
+
+# Project
+from hyperglass.log import log
+from hyperglass.models import HyperglassModel
+from hyperglass.constants import TRANSPORT_REST, SUPPORTED_STRUCTURED_OUTPUT
+from hyperglass.exceptions import ConfigError, ConfigInvalid
+from hyperglass.models.commands import Commands
+
+
+def validate_nos_commands(all_nos: List[str], commands: Commands) -> bool:
+ """Ensure defined devices have associated commands."""
+ custom_commands = commands.dict().keys()
+
+ for nos in all_nos:
+ valid = False
+ if nos in (*SUPPORTED_STRUCTURED_OUTPUT, *TRANSPORT_REST, *custom_commands):
+ valid = True
+
+ if not valid:
+ raise ConfigError(
+ '"{nos}" is used on a device, '
+ + 'but no command profile for "{nos}" is defined.',
+ nos=nos,
+ )
+
+ return True
+
+
+def validate_config(config: Union[Dict, List], importer: Callable) -> HyperglassModel:
+ """Validate a config dict against a model."""
+ validated = None
+ try:
+ if isinstance(config, Dict):
+ validated = importer(**config)
+ elif isinstance(config, List):
+ validated = importer(config)
+ except ValidationError as err:
+ log.error(str(err))
+ raise ConfigInvalid(err.errors()) from None
+
+ return validated
diff --git a/hyperglass/execution/drivers/ssh_netmiko.py b/hyperglass/execution/drivers/ssh_netmiko.py
index 88a65a7..6ed33cb 100644
--- a/hyperglass/execution/drivers/ssh_netmiko.py
+++ b/hyperglass/execution/drivers/ssh_netmiko.py
@@ -71,6 +71,11 @@ class NetmikoConnection(SSHConnection):
**global_args,
}
+ if "_telnet" in self.device.nos:
+ # Telnet devices with a low delay factor (default) tend to
+ # throw login errors.
+ driver_kwargs["global_delay_factor"] = 2
+
if self.device.credential._method == "password":
# Use password auth if no key is defined.
driver_kwargs[
diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py
index c8a804c..919305d 100644
--- a/hyperglass/models/config/devices.py
+++ b/hyperglass/models/config/devices.py
@@ -155,18 +155,16 @@ class Device(HyperglassModel):
return value
@validator("commands", always=True)
- def validate_commands(cls, value, values):
- """If a named command profile is not defined, use the NOS name.
-
- Arguments:
- value {str} -- Reference to command profile
- values {dict} -- Other already-validated fields
-
- Returns:
- {str} -- Command profile or NOS name
- """
+ def validate_commands(cls, value: str, values: "Device") -> str:
+ """If a named command profile is not defined, use the NOS name."""
if value is None:
value = values["nos"]
+
+ # If the _telnet prefix is added, remove it from the command
+ # profile so the commands are the same regardless of
+ # protocol.
+ if "_telnet" in value:
+ value = value.replace("_telnet", "")
return value
@validator("vrfs", pre=True)
@@ -282,7 +280,7 @@ class Devices(HyperglassModelExtra):
# classes, for when iteration over all routers is required.
hostnames.add(device.name)
objects.add(device)
- all_nos.add(device.nos)
+ all_nos.add(device.commands)
for vrf in device.vrfs:
diff --git a/hyperglass/util/__init__.py b/hyperglass/util/__init__.py
index d2b3ef0..9ae0a64 100644
--- a/hyperglass/util/__init__.py
+++ b/hyperglass/util/__init__.py
@@ -868,14 +868,14 @@ def make_repr(_class):
def validate_nos(nos):
"""Validate device NOS is supported."""
# Third Party
- from netmiko.ssh_dispatcher import CLASS_MAPPER_BASE
+ from netmiko.ssh_dispatcher import CLASS_MAPPER
# Project
from hyperglass.constants import DRIVER_MAP
result = (False, None)
- all_nos = {*DRIVER_MAP.keys(), *CLASS_MAPPER_BASE.keys()}
+ all_nos = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()}
if nos in all_nos:
result = (True, DRIVER_MAP.get(nos, "netmiko"))