1
0
mirror of https://github.com/checktheroads/hyperglass synced 2024-05-11 05:55:08 +00:00

implement generic http client; remove hyperglass-agent connection handler

This commit is contained in:
thatmattlove
2021-11-07 01:19:29 -07:00
parent c8f678f766
commit 55f8a62fb0
11 changed files with 362 additions and 243 deletions

View File

@@ -80,4 +80,5 @@ DRIVER_MAP = {
"bird_legacy": "hyperglass_agent", "bird_legacy": "hyperglass_agent",
"bird": "netmiko", "bird": "netmiko",
"frr": "netmiko", "frr": "netmiko",
"http": "hyperglass_http_client",
} }

View File

@@ -1,12 +1,12 @@
"""Individual transport driver classes & subclasses.""" """Individual transport driver classes & subclasses."""
# Local # Local
from .agent import AgentConnection
from ._common import Connection from ._common import Connection
from .http_client import HttpClient
from .ssh_netmiko import NetmikoConnection from .ssh_netmiko import NetmikoConnection
__all__ = ( __all__ = (
"AgentConnection",
"Connection", "Connection",
"HttpClient",
"NetmikoConnection", "NetmikoConnection",
) )

View File

@@ -1,124 +0,0 @@
"""Execute validated & constructed query on device.
Accepts input from front end application, validates the input and
returns errors if input is invalid. Passes validated parameters to
construct.py, which is used to build & run the Netmiko connections or
hyperglass-frr API calls, returns the output back to the front end.
"""
# Standard Library
from ssl import CertificateError
from typing import TYPE_CHECKING, Iterable
# Third Party
import httpx
# Project
from hyperglass.log import log
from hyperglass.util import parse_exception
from hyperglass.state import use_state
from hyperglass.encode import jwt_decode, jwt_encode
from hyperglass.exceptions.public import RestError, ResponseEmpty
# Local
from ._common import Connection
if TYPE_CHECKING:
# Project
from hyperglass.compat._sshtunnel import SSHTunnelForwarder
class AgentConnection(Connection):
"""Connect to target device via hyperglass-agent."""
def setup_proxy(self: "Connection") -> "SSHTunnelForwarder":
"""Return a preconfigured sshtunnel.SSHTunnelForwarder instance."""
raise NotImplementedError("AgentConnection does not implement an SSH proxy.")
async def collect(self) -> Iterable: # noqa: C901
"""Connect to a device running hyperglass-agent via HTTP."""
log.debug("Query parameters: {}", self.query)
params = use_state("params")
client_params = {
"headers": {"Content-Type": "application/json"},
"timeout": params.request_timeout,
}
if self.device.ssl is not None and self.device.ssl.enable:
with self.device.ssl.cert.open("r") as file:
cert = file.read()
if not cert:
raise RestError(
"SSL Certificate for device {d} has not been imported",
level="danger",
d=self.device.name,
)
http_protocol = "https"
client_params.update({"verify": str(self.device.ssl.cert)})
log.debug(
(
f"Using {str(self.device.ssl.cert)} to validate connection "
f"to {self.device.name}"
)
)
else:
http_protocol = "http"
endpoint = "{protocol}://{address}:{port}/query/".format(
protocol=http_protocol, address=self.device._target, port=self.device.port
)
log.debug("URL endpoint: {}", endpoint)
try:
async with httpx.AsyncClient(**client_params) as http_client:
responses = ()
for query in self.query:
encoded_query = await jwt_encode(
payload=query,
secret=self.device.credential.password.get_secret_value(),
duration=params.request_timeout,
)
log.debug("Encoded JWT: {}", encoded_query)
raw_response = await http_client.post(endpoint, json={"encoded": encoded_query})
log.debug("HTTP status code: {}", raw_response.status_code)
raw = raw_response.text
log.debug("Raw Response:\n{}", raw)
if raw_response.status_code == 200:
decoded = await jwt_decode(
payload=raw_response.json()["encoded"],
secret=self.device.credential.password.get_secret_value(),
)
log.debug("Decoded Response:\n{}", decoded)
responses += (decoded,)
elif raw_response.status_code == 204:
raise ResponseEmpty(query=self.query_data)
else:
log.error(raw_response.text)
except httpx.exceptions.HTTPError as rest_error:
msg = parse_exception(rest_error)
raise RestError(error=httpx.exceptions.HTTPError(msg), device=self.device)
except OSError as ose:
raise RestError(error=ose, device=self.device)
except CertificateError as cert_error:
msg = parse_exception(cert_error)
raise RestError(error=CertificateError(cert_error), device=self.device)
if raw_response.status_code != 200:
raise RestError(
error=ConnectionError(f"Response code {raw_response.status_code}"),
device=self.device,
)
if not responses:
raise ResponseEmpty(query=self.query_data)
return responses

View File

@@ -0,0 +1,122 @@
"""Interact with an http-based device."""
# Standard Library
import typing as t
# Third Party
import httpx
# Project
from hyperglass.util import get_fmt_keys
from hyperglass.exceptions.public import (
AuthError,
RestError,
DeviceTimeout,
ResponseEmpty,
)
# Local
from ._common import Connection
if t.TYPE_CHECKING:
# Project
from hyperglass.models.api import Query
from hyperglass.models.config.devices import Device
from hyperglass.models.config.http_client import HttpConfiguration
class HttpClient(Connection):
"""Interact with an http-based device."""
config: "HttpConfiguration"
client: httpx.AsyncClient
def __init__(self, device: "Device", query_data: "Query") -> None:
"""Initialize base connection and set http config & client."""
super().__init__(device, query_data)
self.config = device.http
self.client = self.config.create_client(device=device)
def setup_proxy(self: "Connection"):
"""HTTP Client does not support SSH proxies."""
raise NotImplementedError("HTTP Client does not support SSH proxies.")
def _query_params(self) -> t.Dict[str, str]:
if self.config.query is None:
return {
self.config._attribute_map.query_target: self.query_data.query_target,
self.config._attribute_map.query_location: self.query_data.query_location,
self.config._attribute_map.query_type: self.query_data.query_type,
}
elif isinstance(self.config.query, t.Dict):
return {
key: value.format(
**{
str(v): str(getattr(self.query_data, k, None))
for k, v in self.config.attribute_map.dict().items()
if v in get_fmt_keys(value)
}
)
for key, value in self.config.query.items()
}
return {}
def _body(self) -> t.Dict[str, t.Union[t.Dict[str, t.Any], str]]:
data = {
self.config._attribute_map.query_target: self.query_data.query_target,
self.config._attribute_map.query_location: self.query_data.query_location,
self.config._attribute_map.query_type: self.query_data.query_type,
}
if self.config.body_format == "json":
return {"json": data}
elif self.config.body_format == "yaml":
# Third Party
import yaml
return {"content": yaml.dump(data), "headers": {"content-type": "text/yaml"}}
elif self.config.body_format == "xml":
# Third Party
import xmltodict # type: ignore
return {
"content": xmltodict.unparse({"query": data}),
"headers": {"content-type": "application/xml"},
}
elif self.config.body_format == "text":
return {"data": data}
return {}
async def collect(self, *args: t.Any, **kwargs: t.Any) -> t.Iterable:
"""Collect response data from an HTTP endpoint."""
query = self._query_params()
responses = ()
async with self.client as client:
body = {}
if self.config.method in ("POST", "PATCH", "PUT"):
body = self._body()
try:
response: httpx.Response = await client.request(
method=self.config.method, url=self.config.path, params=query, **body
)
response.raise_for_status()
data = response.text.strip()
if len(data) == 0:
raise ResponseEmpty(query=self.query_data)
responses += (data,)
except (httpx.TimeoutException) as error:
raise DeviceTimeout(error=error, device=self.device)
except (httpx.HTTPStatusError) as error:
if error.response.status_code == 401:
raise AuthError(error=error, device=self.device)
raise RestError(error=error, device=self.device)
return responses

View File

@@ -3,7 +3,7 @@
Accepts input from front end application, validates the input and Accepts input from front end application, validates the input and
returns errors if input is invalid. Passes validated parameters to returns errors if input is invalid. Passes validated parameters to
construct.py, which is used to build & run the Netmiko connections or construct.py, which is used to build & run the Netmiko connections or
hyperglass-frr API calls, returns the output back to the front end. http client API calls, returns the output back to the front end.
""" """
# Standard Library # Standard Library
@@ -22,14 +22,14 @@ if TYPE_CHECKING:
from hyperglass.models.data import OutputDataModel from hyperglass.models.data import OutputDataModel
# Local # Local
from .drivers import AgentConnection, NetmikoConnection from .drivers import HttpClient, NetmikoConnection
def map_driver(driver_name: str) -> "Connection": def map_driver(driver_name: str) -> "Connection":
"""Get the correct driver class based on the driver name.""" """Get the correct driver class based on the driver name."""
if driver_name == "hyperglass_agent": if driver_name == "hyperglass_http_client":
return AgentConnection return HttpClient
return NetmikoConnection return NetmikoConnection

View File

@@ -19,13 +19,13 @@ from hyperglass.constants import DRIVER_MAP, SCRAPE_HELPERS, SUPPORTED_STRUCTURE
from hyperglass.exceptions.private import ConfigError, UnsupportedDevice from hyperglass.exceptions.private import ConfigError, UnsupportedDevice
# Local # Local
from .ssl import Ssl
from ..main import MultiModel, HyperglassModel, HyperglassModelWithId from ..main import MultiModel, HyperglassModel, HyperglassModelWithId
from ..util import check_legacy_fields from ..util import check_legacy_fields
from .proxy import Proxy from .proxy import Proxy
from ..fields import SupportedDriver from ..fields import SupportedDriver
from ..directive import Directives from ..directive import Directives
from .credential import Credential from .credential import Credential
from .http_client import HttpConfiguration
ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()} ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()}
@@ -49,7 +49,7 @@ class Device(HyperglassModelWithId, extra="allow"):
proxy: Optional[Proxy] proxy: Optional[Proxy]
display_name: Optional[StrictStr] display_name: Optional[StrictStr]
port: StrictInt = 22 port: StrictInt = 22
ssl: Optional[Ssl] http: HttpConfiguration = HttpConfiguration()
platform: StrictStr platform: StrictStr
structured_output: Optional[StrictBool] structured_output: Optional[StrictBool]
directives: Directives = Directives() directives: Directives = Directives()
@@ -219,19 +219,6 @@ class Device(HyperglassModelWithId, extra="allow"):
value = False value = False
return value return value
@validator("ssl")
def validate_ssl(cls, value, values):
"""Set default cert file location if undefined."""
if value is not None:
if value.enable and value.cert is None:
cert_file = Settings.app_path / "certs" / f'{values["name"]}.pem'
if not cert_file.exists():
log.warning("No certificate found for device {d}", d=values["name"])
cert_file.touch()
value.cert = cert_file
return value
@validator("directives", pre=True, always=True) @validator("directives", pre=True, always=True)
def validate_directives(cls: "Device", value, values) -> "Directives": def validate_directives(cls: "Device", value, values) -> "Directives":
"""Associate directive IDs to loaded directive objects.""" """Associate directive IDs to loaded directive objects."""

View File

@@ -0,0 +1,138 @@
"""Configuration models for hyperglass http client."""
# Standard Library
import typing as t
# Third Party
import httpx
from pydantic import (
FilePath,
SecretStr,
StrictInt,
StrictStr,
StrictBool,
PrivateAttr,
IPvAnyAddress,
)
# Project
from hyperglass.models import HyperglassModel
from hyperglass.constants import __version__
# Local
from ..fields import IntFloat, HttpMethod, Primitives
if t.TYPE_CHECKING:
# Local
from .devices import Device
DEFAULT_QUERY_PARAMETERS: t.Dict[str, str] = {
"query_target": "{query_target}",
"query_type": "{query_type}",
"query_location": "{query_location}",
}
BodyFormat = t.Literal["json", "yaml", "xml", "text"]
Scheme = t.Literal["http", "https"]
class AttributeMapConfig(HyperglassModel):
"""Allow the user to 'rewrite' hyperglass field names to their own values."""
query_target: t.Optional[StrictStr]
query_type: t.Optional[StrictStr]
query_location: t.Optional[StrictStr]
class AttributeMap(HyperglassModel):
"""Merged implementation of attribute map configuration."""
query_target: StrictStr
query_type: StrictStr
query_location: StrictStr
class HttpBasicAuth(HyperglassModel):
"""Configuration model for HTTP basic authentication."""
username: StrictStr
password: SecretStr
class HttpConfiguration(HyperglassModel):
"""HTTP client configuration."""
_attribute_map: AttributeMap = PrivateAttr()
path: StrictStr = "/"
method: HttpMethod = "GET"
scheme: Scheme = "https"
query: t.Optional[t.Union[t.Literal[False], t.Dict[str, Primitives]]]
verify_ssl: StrictBool = True
ssl_ca: t.Optional[FilePath]
ssl_client: t.Optional[FilePath]
source: t.Optional[IPvAnyAddress]
timeout: IntFloat = 5
headers: t.Dict[str, str] = {}
follow_redirects: StrictBool = False
basic_auth: t.Optional[HttpBasicAuth]
attribute_map: AttributeMapConfig = AttributeMapConfig()
body_format: BodyFormat = "json"
retries: StrictInt = 0
def __init__(self, **data: t.Any) -> None:
"""Create HTTP Client Configuration Definition."""
super().__init__(**data)
self._attribute_map = self._create_attribute_map()
def _create_attribute_map(self) -> AttributeMap:
"""Create AttributeMap instance with defined overrides."""
return AttributeMap(
query_location=self.attribute_map.query_location or "query_location",
query_type=self.attribute_map.query_type or "query_type",
query_target=self.attribute_map.query_target or "query_target",
)
def create_client(self, *, device: "Device") -> httpx.AsyncClient:
"""Create a pre-configured http client."""
# Use the CA certificates for SSL verification, if present.
verify = self.verify_ssl
if self.ssl_ca is not None:
verify = httpx.create_ssl_context(verify=str(self.ssl_ca))
transport_constructor = {"retries": self.retries}
# Use `source` IP address as httpx transport's `local_address`, if defined.
if self.source is not None:
transport_constructor["local_address"] = str(self.source)
transport = httpx.AsyncHTTPTransport(**transport_constructor)
# Add the port to the URL only if it is not 22, 80, or 443.
base_url = f"{self.scheme}://{device.address!s}".strip("/")
if device.port not in (22, 80, 443):
base_url += f":{device.port!s}"
parameters = {
"verify": verify,
"transport": transport,
"timeout": self.timeout,
"follow_redirects": self.follow_redirects,
"base_url": f"{self.scheme}://{device.address!s}".strip("/"),
"headers": {"user-agent": f"hyperglass/{__version__}", **self.headers},
}
# Use client certificate authentication, if defined.
if self.ssl_client is not None:
parameters["cert"] = str(self.ssl_client)
# Use basic authentication, if defined.
if self.basic_auth is not None:
parameters["auth"] = httpx.BasicAuth(
username=self.basic_auth.username,
password=self.basic_auth.password.get_secret_value(),
)
return httpx.AsyncClient(**parameters)

View File

@@ -1,33 +0,0 @@
"""Validate SSL configuration variables."""
# Standard Library
from typing import Optional
# Third Party
from pydantic import Field, FilePath, StrictBool
# Local
from ..main import HyperglassModel
class Ssl(HyperglassModel):
"""Validate SSL config parameters."""
enable: StrictBool = Field(
True,
title="Enable SSL",
description="If enabled, hyperglass will use HTTPS to connect to the configured device running [hyperglass-agent](/fixme). If enabled, a certificate file must be specified (hyperglass does not support connecting to a device over an unverified SSL session.)",
)
cert: Optional[FilePath]
class Config:
"""Pydantic model configuration."""
title = "SSL"
description = "SSL configuration for devices running hyperglass-agent."
fields = {
"cert": {
"title": "Certificate",
"description": "Valid path to an SSL certificate. This certificate must be the public key used to serve the hyperglass-agent API on the device running hyperglass-agent.",
}
}

View File

@@ -13,6 +13,7 @@ SupportedDriver = t.Literal["netmiko", "hyperglass_agent"]
HttpAuthMode = t.Literal["basic", "api_key"] HttpAuthMode = t.Literal["basic", "api_key"]
HttpProvider = t.Literal["msteams", "slack", "generic"] HttpProvider = t.Literal["msteams", "slack", "generic"]
LogFormat = t.Literal["text", "json"] LogFormat = t.Literal["text", "json"]
Primitives = t.Union[None, float, int, bool, str]
class AnyUri(str): class AnyUri(str):
@@ -71,3 +72,40 @@ class Action(str):
def __repr__(self): def __repr__(self):
"""Stringify custom field representation.""" """Stringify custom field representation."""
return f"Action({super().__repr__()})" return f"Action({super().__repr__()})"
class HttpMethod(str):
"""Custom field type for HTTP methods."""
methods = (
"CONNECT",
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"TRACE",
)
@classmethod
def __get_validators__(cls):
"""Pydantic custom field method."""
yield cls.validate
@classmethod
def validate(cls, value: str):
"""Ensure http method is valid."""
if not isinstance(value, str):
raise TypeError("HTTP Method must be a string")
value = value.strip().upper()
if value in cls.methods:
return cls(value)
raise ValueError("HTTP Method must be one of {!r}".format(", ".join(cls.methods)))
def __repr__(self):
"""Stringify custom field representation."""
return f"HttpMethod({super().__repr__()})"

118
poetry.lock generated
View File

@@ -6,6 +6,23 @@ category = "main"
optional = false optional = false
python-versions = ">=3.6,<4.0" python-versions = ">=3.6,<4.0"
[[package]]
name = "anyio"
version = "3.3.4"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16)"]
[[package]] [[package]]
name = "appdirs" name = "appdirs"
version = "1.4.4" version = "1.4.4"
@@ -25,26 +42,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies] [package.dependencies]
pyyaml = "*" pyyaml = "*"
[[package]]
name = "asyncssh"
version = "2.7.0"
description = "AsyncSSH: Asynchronous SSHv2 client and server library"
category = "main"
optional = false
python-versions = ">= 3.6"
[package.dependencies]
cryptography = ">=2.8"
[package.extras]
bcrypt = ["bcrypt (>=3.1.3)"]
fido2 = ["fido2 (==0.9.1)"]
gssapi = ["gssapi (>=1.2.0)"]
libnacl = ["libnacl (>=1.4.2)"]
pkcs11 = ["python-pkcs11 (>=0.7.0)"]
pyOpenSSL = ["pyOpenSSL (>=17.0.0)"]
pywin32 = ["pywin32 (>=227)"]
[[package]] [[package]]
name = "atomicwrites" name = "atomicwrites"
version = "1.4.0" version = "1.4.0"
@@ -145,6 +142,17 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.6.1" python-versions = ">=3.6.1"
[[package]]
name = "charset-normalizer"
version = "2.0.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]] [[package]]
name = "click" name = "click"
version = "7.1.2" version = "7.1.2"
@@ -508,22 +516,23 @@ tornado = ["tornado (>=0.2)"]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.9.0" version = "0.12.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = ">=3.6"
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "0.12.3" version = "0.13.7"
description = "A minimal low-level HTTP client." description = "A minimal low-level HTTP client."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
[package.dependencies] [package.dependencies]
h11 = "<1.0.0" anyio = ">=3.0.0,<4.0.0"
h11 = ">=0.11,<0.13"
sniffio = ">=1.0.0,<2.0.0" sniffio = ">=1.0.0,<2.0.0"
[package.extras] [package.extras]
@@ -542,7 +551,7 @@ test = ["Cython (==0.29.14)"]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.17.1" version = "0.20.0"
description = "The next generation HTTP client." description = "The next generation HTTP client."
category = "main" category = "main"
optional = false optional = false
@@ -550,13 +559,15 @@ python-versions = ">=3.6"
[package.dependencies] [package.dependencies]
certifi = "*" certifi = "*"
httpcore = ">=0.12.1,<0.13" charset-normalizer = "*"
httpcore = ">=0.13.3,<0.14.0"
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
sniffio = "*" sniffio = "*"
[package.extras] [package.extras]
brotli = ["brotlipy (>=0.7.0,<0.8.0)"] brotli = ["brotlicffi", "brotli"]
http2 = ["h2 (>=3.0.0,<4.0.0)"] cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"]
http2 = ["h2 (>=3,<5)"]
[[package]] [[package]]
name = "identify" name = "identify"
@@ -1032,27 +1043,6 @@ python-versions = "*"
[package.dependencies] [package.dependencies]
paramiko = "*" paramiko = "*"
[[package]]
name = "scrapli"
version = "2021.7.30"
description = "Fast, flexible, sync/async, Python 3.6+ screen scraping client specifically for network devices"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
asyncssh = {version = ">=2.2.1,<3.0.0", optional = true, markers = "extra == \"asyncssh\""}
[package.extras]
asyncssh = ["asyncssh (>=2.2.1,<3.0.0)"]
community = ["scrapli-community (>=2021.01.30)"]
full = ["ntc-templates (>=1.1.0,<3.0.0)", "textfsm (>=1.1.0,<2.0.0)", "ttp (>=0.5.0,<1.0.0)", "paramiko (>=2.6.0,<3.0.0)", "asyncssh (>=2.2.1,<3.0.0)", "scrapli-community (>=2021.01.30)", "ssh2-python (>=0.23.0,<1.0.0)", "genie (>=20.2)", "pyats (>=20.2)"]
genie = ["genie (>=20.2)", "pyats (>=20.2)"]
paramiko = ["paramiko (>=2.6.0,<3.0.0)"]
ssh2 = ["ssh2-python (>=0.23.0,<1.0.0)"]
textfsm = ["ntc-templates (>=1.1.0,<3.0.0)", "textfsm (>=1.1.0,<2.0.0)"]
ttp = ["ttp (>=0.5.0,<1.0.0)"]
[[package]] [[package]]
name = "six" name = "six"
version = "1.15.0" version = "1.15.0"
@@ -1332,13 +1322,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 = "d4a0600f54f56ba3641943af10908d325a459f301c073ec0f2f354ca7869d0ed" content-hash = "ba2c36614f210b1e9a0fe576a7854bef0e03f678da7b7d5eba724cb794c7baf1"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
{file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"}, {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
{file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"}, {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
] ]
anyio = [
{file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"},
{file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"},
]
appdirs = [ appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
@@ -1347,10 +1341,6 @@ appdirs = [
{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"},
] ]
asyncssh = [
{file = "asyncssh-2.7.0-py3-none-any.whl", hash = "sha256:ccc62a1b311c71d4bf8e4bc3ac141eb00ebb28b324e375aed1d0a03232893ca1"},
{file = "asyncssh-2.7.0.tar.gz", hash = "sha256:185013d8e67747c3c0f01b72416b8bd78417da1df48c71f76da53c607ef541b6"},
]
atomicwrites = [ atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
@@ -1422,6 +1412,10 @@ cfgv = [
{file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
{file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
] ]
charset-normalizer = [
{file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
{file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
]
click = [ click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
@@ -1559,12 +1553,12 @@ gunicorn = [
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
] ]
h11 = [ h11 = [
{file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
{file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
] ]
httpcore = [ httpcore = [
{file = "httpcore-0.12.3-py3-none-any.whl", hash = "sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"}, {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"},
{file = "httpcore-0.12.3.tar.gz", hash = "sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9"}, {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"},
] ]
httptools = [ httptools = [
{file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"}, {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"},
@@ -1581,8 +1575,8 @@ httptools = [
{file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"}, {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"},
] ]
httpx = [ httpx = [
{file = "httpx-0.17.1-py3-none-any.whl", hash = "sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272"}, {file = "httpx-0.20.0-py3-none-any.whl", hash = "sha256:33af5aad9bdc82ef1fc89219c1e36f5693bf9cd0ebe330884df563445682c0f8"},
{file = "httpx-0.17.1.tar.gz", hash = "sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967"}, {file = "httpx-0.20.0.tar.gz", hash = "sha256:09606d630f070d07f9ff28104fbcea429ea0014c1e89ac90b4d8de8286c40e7b"},
] ]
identify = [ identify = [
{file = "identify-1.5.5-py2.py3-none-any.whl", hash = "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"}, {file = "identify-1.5.5-py2.py3-none-any.whl", hash = "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"},
@@ -1942,10 +1936,6 @@ scp = [
{file = "scp-0.13.3-py2.py3-none-any.whl", hash = "sha256:f2fa9fb269ead0f09b4e2ceb47621beb7000c135f272f6b70d3d9d29928d7bf0"}, {file = "scp-0.13.3-py2.py3-none-any.whl", hash = "sha256:f2fa9fb269ead0f09b4e2ceb47621beb7000c135f272f6b70d3d9d29928d7bf0"},
{file = "scp-0.13.3.tar.gz", hash = "sha256:8bd748293d7362073169b96ce4b8c4f93bcc62cfc5f7e1d949e01e406a025bd4"}, {file = "scp-0.13.3.tar.gz", hash = "sha256:8bd748293d7362073169b96ce4b8c4f93bcc62cfc5f7e1d949e01e406a025bd4"},
] ]
scrapli = [
{file = "scrapli-2021.7.30-py3-none-any.whl", hash = "sha256:7bdf482a79d0a3d24a9a776b8d82686bc201a4c828fd14a917453177c0008d98"},
{file = "scrapli-2021.7.30.tar.gz", hash = "sha256:fa1e27a7f6281e6ea8ae8bb096b637b2f5b0ecf37251160b839577a1c0cef40f"},
]
six = [ six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},

View File

@@ -38,7 +38,7 @@ distro = "^1.5.0"
fastapi = "^0.63.0" fastapi = "^0.63.0"
favicons = ">=0.1.0,<1.0" favicons = ">=0.1.0,<1.0"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
httpx = "^0.17.1" httpx = "^0.20.0"
loguru = "^0.5.3" loguru = "^0.5.3"
netmiko = "^3.4.0" netmiko = "^3.4.0"
paramiko = "^2.7.2" paramiko = "^2.7.2"