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

migrate sanic → FastAPI

This commit is contained in:
checktheroads
2020-01-20 00:32:42 -07:00
parent 93ce589ae1
commit c469e6576c
4 changed files with 86 additions and 274 deletions

View File

@@ -44,7 +44,7 @@ class HyperglassError(Exception):
""" """
return f"[{self.alert.upper()}] {self._message}" return f"[{self.alert.upper()}] {self._message}"
def __dict__(self): def dict(self):
"""Return the instance's attributes as a dictionary. """Return the instance's attributes as a dictionary.
Returns: Returns:

View File

@@ -9,22 +9,18 @@ from pathlib import Path
# Third Party Imports # Third Party Imports
import aredis import aredis
from aiofile import AIOFile from fastapi import FastAPI
from fastapi import HTTPException
from prometheus_client import CONTENT_TYPE_LATEST from prometheus_client import CONTENT_TYPE_LATEST
from prometheus_client import CollectorRegistry from prometheus_client import CollectorRegistry
from prometheus_client import Counter from prometheus_client import Counter
from prometheus_client import generate_latest from prometheus_client import generate_latest
from prometheus_client import multiprocess from prometheus_client import multiprocess
from sanic import Sanic from starlette.exceptions import HTTPException as StarletteHTTPException
from sanic import response as sanic_response from starlette.requests import Request
from sanic.exceptions import InvalidUsage from starlette.responses import PlainTextResponse
from sanic.exceptions import NotFound from starlette.responses import UJSONResponse
from sanic.exceptions import ServerError from starlette.staticfiles import StaticFiles
from sanic.exceptions import ServiceUnavailable
from sanic_limiter import Limiter
# from sanic_limiter import RateLimitExceeded
from sanic_limiter import get_remote_address
# Project Imports # Project Imports
from hyperglass.configuration import frontend_params from hyperglass.configuration import frontend_params
@@ -40,7 +36,6 @@ from hyperglass.exceptions import ScrapeError
from hyperglass.execution.execute import Execute from hyperglass.execution.execute import Execute
from hyperglass.models.query import Query from hyperglass.models.query import Query
from hyperglass.util import check_python from hyperglass.util import check_python
from hyperglass.util import cpu_count
from hyperglass.util import log from hyperglass.util import log
# Verify Python version meets minimum requirement # Verify Python version meets minimum requirement
@@ -60,28 +55,19 @@ STATIC_DIR = Path(__file__).parent / "static"
UI_DIR = STATIC_DIR / "ui" UI_DIR = STATIC_DIR / "ui"
IMAGES_DIR = STATIC_DIR / "images" IMAGES_DIR = STATIC_DIR / "images"
NEXT_DIR = UI_DIR / "_next" NEXT_DIR = UI_DIR / "_next"
INDEX = UI_DIR / "index.html"
NOTFOUND = UI_DIR / "404.html"
NOFLASH = UI_DIR / "noflash.js"
log.debug(f"Static Files: {STATIC_DIR}") log.debug(f"Static Files: {STATIC_DIR}")
# Main Sanic App Definition # Main App Definition
app = Sanic(__name__) app = FastAPI()
app.static("/ui", str(UI_DIR)) app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
app.static("/_next", str(NEXT_DIR)) app.mount("/_next", StaticFiles(directory=NEXT_DIR), name="_next")
app.static("/images", str(IMAGES_DIR)) app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images")
app.static("/ui/images", str(IMAGES_DIR)) app.mount("/ui/images", StaticFiles(directory=IMAGES_DIR), name="ui/images")
app.static("/noflash.js", str(NOFLASH))
log.debug(app.config)
# Sanic Web Server Parameters
APP_PARAMS = { APP_PARAMS = {
"host": params.general.listen_address, "host": str(params.general.listen_address),
"port": params.general.listen_port, "port": params.general.listen_port,
"debug": params.general.debug, "debug": params.general.debug,
"workers": cpu_count(),
"access_log": params.general.debug,
"auto_reload": params.general.debug,
} }
# Redis Config # Redis Config
@@ -91,26 +77,7 @@ redis_config = {
"decode_responses": True, "decode_responses": True,
} }
# Sanic-Limiter Config
query_rate = params.features.rate_limit.query.rate
query_period = params.features.rate_limit.query.period
site_rate = params.features.rate_limit.site.rate
site_period = params.features.rate_limit.site.period
rate_limit_query = f"{query_rate} per {query_period}"
rate_limit_site = f"{site_rate} per {site_period}"
log.debug(f"Query rate limit: {rate_limit_query}")
log.debug(f"Site rate limit: {rate_limit_site}")
# Redis Config for Sanic-Limiter storage
r_limiter_db = params.features.rate_limit.redis_id
r_limiter_url = "redis://{host}:{port}/{db}".format(
host=params.general.redis_host,
port=params.general.redis_port,
db=params.features.rate_limit.redis_id,
)
r_cache = aredis.StrictRedis(db=params.features.cache.redis_id, **redis_config) r_cache = aredis.StrictRedis(db=params.features.cache.redis_id, **redis_config)
r_limiter = aredis.StrictRedis(db=params.features.rate_limit.redis_id, **redis_config)
async def check_redis(): async def check_redis():
@@ -124,7 +91,7 @@ async def check_redis():
""" """
try: try:
await r_cache.echo("hyperglass test") await r_cache.echo("hyperglass test")
await r_limiter.echo("hyperglass test") # await r_limiter.echo("hyperglass test")
except Exception: except Exception:
raise HyperglassError( raise HyperglassError(
f"Redis isn't running at: {redis_config['host']}:{redis_config['port']}", f"Redis isn't running at: {redis_config['host']}:{redis_config['port']}",
@@ -136,11 +103,6 @@ async def check_redis():
# Verify Redis is running # Verify Redis is running
asyncio.run(check_redis()) asyncio.run(check_redis())
# Adds Sanic config variable for Sanic-Limiter
app.config.update(RATELIMIT_STORAGE_URL=r_limiter_url)
# Initializes Sanic-Limiter
limiter = Limiter(app, key_func=get_remote_address, global_limits=[rate_limit_site])
# Prometheus Config # Prometheus Config
count_data = Counter( count_data = Counter(
@@ -162,29 +124,13 @@ count_notfound = Counter(
) )
@app.middleware("request") @app.get("/metrics")
async def request_middleware(request):
"""Respond to OPTIONS methods."""
if request.method == "OPTIONS": # noqa: R503
return sanic_response.json({"content": "ok"}, status=204)
@app.middleware("response")
async def response_middleware(request, response):
"""Add CORS headers to responses."""
response.headers.add("Access-Control-Allow-Origin", "*")
response.headers.add("Access-Control-Allow-Headers", "Content-Type")
response.headers.add("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
@app.route("/metrics")
@limiter.exempt
async def metrics(request): async def metrics(request):
"""Serve Prometheus metrics.""" """Serve Prometheus metrics."""
registry = CollectorRegistry() registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry) multiprocess.MultiProcessCollector(registry)
latest = generate_latest(registry) latest = generate_latest(registry)
return sanic_response.text( return PlainTextResponse(
latest, latest,
headers={ headers={
"Content-Type": CONTENT_TYPE_LATEST, "Content-Type": CONTENT_TYPE_LATEST,
@@ -193,83 +139,28 @@ async def metrics(request):
) )
@app.exception(InvalidUsage) @app.exception_handler(StarletteHTTPException)
async def handle_frontend_errors(request, exception): async def http_exception_handler(request, exc):
"""Handle user-facing feedback related to frontend/input errors.""" """Handle application errors."""
client_addr = get_remote_address(request) return UJSONResponse(
error = exception.args[0] {
alert = error["alert"] "output": exc.detail.get("message", params.messages.general),
log.info(error) "alert": exc.detail.get("alert", "error"),
count_errors.labels( "keywords": exc.detail.get("keywords", []),
"Front End Error", },
client_addr, status_code=exc.status_code,
request.json.get("query_type"),
request.json.get("location"),
request.json.get("target"),
).inc()
log.error(f'Error: {error["message"]}, Source: {client_addr}')
return sanic_response.json(
{"output": error["message"], "alert": alert, "keywords": error["keywords"]},
status=400,
) )
@app.exception(ServiceUnavailable) @app.exception_handler(HyperglassError)
async def handle_backend_errors(request, exception): async def http_exception_handler(request, exc):
"""Handle user-facing feedback related to backend errors.""" """Handle request validation errors."""
client_addr = get_remote_address(request) return UJSONResponse(
error = exception.args[0] {"output": exc.message, "alert": exc.alert, "keywords": exc.keywords},
alert = error["alert"] status_code=400,
log.info(error)
count_errors.labels(
"Back End Error",
client_addr,
request.json.get("query_type"),
request.json.get("location"),
request.json.get("target"),
).inc()
log.error(f'Error: {error["message"]}, Source: {client_addr}')
return sanic_response.json(
{"output": error["message"], "alert": alert, "keywords": error["keywords"]},
status=503,
) )
@app.exception(NotFound)
async def handle_404(request, exception):
"""Render full error page for invalid URI."""
path = request.path
# html = render_html("404", uri=path)
client_addr = get_remote_address(request)
count_notfound.labels(exception, path, client_addr).inc()
log.error(f"Error: {exception}, Path: {path}, Source: {client_addr}")
# return sanic_response.html(html, status=404)
async with AIOFile(NOTFOUND, "r") as nf:
html = await nf.read()
return sanic_response.html(html)
# @app.exception(RateLimitExceeded)
# async def handle_429(request, exception):
# """Render full error page for too many site queries."""
# html = render_html("ratelimit-site")
# client_addr = get_remote_address(request)
# count_ratelimit.labels(exception, client_addr).inc()
# log.error(f"Error: {exception}, Source: {client_addr}")
# return sanic_response.html(html, status=429)
# @app.exception(ServerError)
# async def handle_500(request, exception):
# """Render general error page."""
# client_addr = get_remote_address(request)
# count_errors.labels(500, exception, client_addr, None, None, None).inc()
# log.error(f"Error: {exception}, Source: {client_addr}")
# html = render_html("500")
# return sanic_response.html(html, status=500)
async def clear_cache(): async def clear_cache():
"""Clear the Redis cache.""" """Clear the Redis cache."""
try: try:
@@ -280,57 +171,27 @@ async def clear_cache():
raise HyperglassError(f"Error clearing cache: {error_exception}") raise HyperglassError(f"Error clearing cache: {error_exception}")
@app.route("/", methods=["GET", "OPTIONS"]) @app.get("/config")
@limiter.limit(rate_limit_site, error_message="Site") async def frontend_config():
async def site(request):
"""Serve main application front end."""
# html = await render_html("form", primary_asn=params.general.primary_asn)
# return sanic_response.html(html)
async with AIOFile(INDEX, "r") as entry:
html = await entry.read()
return sanic_response.html(html)
@app.route("/config", methods=["GET", "OPTIONS"])
async def frontend_config(request):
"""Provide validated user/default config for front end consumption. """Provide validated user/default config for front end consumption.
Returns: Returns:
{dict} -- Filtered configuration {dict} -- Filtered configuration
""" """
return sanic_response.json(frontend_params) return UJSONResponse(frontend_params, status_code=200)
@app.route("/query", methods=["POST", "OPTIONS"]) @app.post("/query/")
@limiter.limit( async def hyperglass_main(query_data: Query, request: Request):
rate_limit_query,
error_message={
"output": params.features.rate_limit.query.message,
"alert": "danger",
"keywords": [],
},
)
async def hyperglass_main(request):
"""Process XHR POST data. """Process XHR POST data.
Ingests XHR POST data from Ingests XHR POST data from
form submit, passes it to the backend application to perform the form submit, passes it to the backend application to perform the
filtering/lookups. filtering/lookups.
""" """
# Get JSON data from Ajax POST
raw_query_data = request.json
log.debug(f"Unvalidated input: {raw_query_data}")
# Perform basic input validation
# query_data = await validate_input(raw_query_data)
try:
query_data = Query(**raw_query_data)
except InputInvalid as he:
raise InvalidUsage(he.__dict__())
# Get client IP address for Prometheus logging & rate limiting # Get client IP address for Prometheus logging & rate limiting
client_addr = get_remote_address(request) client_addr = request.client.host
# Increment Prometheus counter # Increment Prometheus counter
count_data.labels( count_data.labels(
@@ -368,13 +229,18 @@ async def hyperglass_main(request):
log.debug(f"Query {cache_key} took {elapsedtime} seconds to run.") log.debug(f"Query {cache_key} took {elapsedtime} seconds to run.")
except (InputInvalid, InputNotAllowed, ResponseEmpty) as frontend_error: except (InputInvalid, InputNotAllowed, ResponseEmpty) as frontend_error:
raise InvalidUsage(frontend_error.__dict__()) raise HTTPException(detail=frontend_error.dict(), status_code=400)
except (AuthError, RestError, ScrapeError, DeviceTimeout) as backend_error: except (AuthError, RestError, ScrapeError, DeviceTimeout) as backend_error:
raise ServiceUnavailable(backend_error.__dict__()) raise HTTPException(detail=backend_error.dict(), status_code=500)
if cache_value is None: if cache_value is None:
raise ServerError( raise HTTPException(
{"message": params.messages.general, "alert": "danger", "keywords": []} detail={
"message": params.messages.general,
"alert": "danger",
"keywords": [],
},
status_code=500,
) )
# Create a cache entry # Create a cache entry
@@ -391,4 +257,12 @@ async def hyperglass_main(request):
log.debug(f"Cache match for: {cache_key}, returning cached entry") log.debug(f"Cache match for: {cache_key}, returning cached entry")
log.debug(f"Cache Output: {response_output}") log.debug(f"Cache Output: {response_output}")
return sanic_response.json({"output": response_output}, status=200) return UJSONResponse(
{"output": response_output},
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
},
)

View File

@@ -2,16 +2,11 @@
def start(): def start():
"""Start Sanic web server.""" """Start the web server with Uvicorn ASGI."""
try: import uvicorn
from hyperglass import hyperglass, APP_PARAMS from hyperglass.hyperglass import app, APP_PARAMS
hyperglass.app.run(**APP_PARAMS) uvicorn.run(app, **APP_PARAMS)
except ImportError as import_err:
raise RuntimeError(str(import_err))
except Exception as web_err:
raise RuntimeError(str(web_err))
app = start() app = start()

105
manage.py
View File

@@ -21,7 +21,6 @@ from pathlib import Path
import click import click
import requests import requests
import stackprinter import stackprinter
from passlib.hash import pbkdf2_sha256
stackprinter.set_excepthook(style="darkbg2") stackprinter.set_excepthook(style="darkbg2")
@@ -40,6 +39,7 @@ WS6 = " "
WS8 = " " WS8 = " "
CL = ":" CL = ":"
E_CHECK = "\U00002705" E_CHECK = "\U00002705"
E_ERROR = "\U0000274C"
E_ROCKET = "\U0001F680" E_ROCKET = "\U0001F680"
E_SPARKLES = "\U00002728" E_SPARKLES = "\U00002728"
@@ -535,102 +535,45 @@ async def clearcache():
raise raise
@hg.command("generate-key", help="Generate API key & hash")
@click.option(
"-l", "--length", "string_length", type=int, default=16, show_default=True
)
def generatekey(string_length):
"""
Generates 16 character API Key for hyperglass-frr API, and a
corresponding PBKDF2 SHA256 Hash.
"""
ld = string.ascii_letters + string.digits
nl = "\n"
api_key = "".join(random.choice(ld) for i in range(string_length))
key_hash = pbkdf2_sha256.hash(api_key)
line_len = len(key_hash)
ak_info = " Your API Key is: "
ak_help1 = " Put this in the"
ak_help2 = " configuration.yaml "
ak_help3 = "of your API module."
kh_info = " Your Key Hash is: "
kh_help1 = " Use this as the password for the corresponding device in"
kh_help2 = " devices.yaml"
kh_help3 = "."
ak_info_len = len(ak_info + api_key)
ak_help_len = len(ak_help1 + ak_help2 + ak_help3)
kh_info_len = len(kh_info + key_hash)
kh_help_len = len(kh_help1 + kh_help2 + kh_help3)
ak_kh = [ak_info_len, ak_help_len, kh_info_len, kh_help_len]
ak_kh.sort()
longest_line = ak_kh[-1] + 2
s_box = {"fg": "white", "dim": True, "bold": True}
s_txt = {"fg": "white"}
s_ak = {"fg": "green", "bold": True}
s_kh = {"fg": "blue", "bold": True}
s_file = {"fg": "yellow"}
click.echo(
click.style("" + ("" * longest_line) + "", **s_box)
+ click.style(nl + "", **s_box)
+ click.style(ak_info, **s_txt)
+ click.style(api_key, **s_ak)
+ click.style(" " * (longest_line - ak_info_len) + "", **s_box)
+ click.style(nl + "", **s_box)
+ click.style(ak_help1, **s_txt)
+ click.style(ak_help2, **s_file)
+ click.style(ak_help3, **s_txt)
+ click.style(" " * (longest_line - ak_help_len) + "", **s_box)
+ click.style(nl + "" + ("" * longest_line) + "", **s_box)
+ click.style(nl + "", **s_box)
+ click.style(kh_info, **s_txt)
+ click.style(key_hash, **s_kh)
+ click.style(" " * (longest_line - kh_info_len) + "", **s_box)
+ click.style(nl + "", **s_box)
+ click.style(kh_help1, **s_txt)
+ click.style(kh_help2, **s_file)
+ click.style(kh_help3, **s_txt)
+ click.style(" " * (longest_line - kh_help_len) + "", **s_box)
+ click.style(nl + "" + ("" * longest_line) + "", **s_box)
)
def start_dev_server(app, params): def start_dev_server(app, params):
"""Starts Sanic development server for testing without WSGI/Reverse Proxy""" """Starts Sanic development server for testing without WSGI/Reverse Proxy"""
import uvicorn
msg_start = "Starting hyperglass web server on"
msg_uri = "http://"
msg_host = str(params["host"])
msg_port = str(params["port"])
msg_len = len("".join([msg_start, WS1, msg_uri, msg_host, CL, msg_port]))
try: try:
click.echo( click.echo(
NL NL
+ WS1 * msg_len
+ WS8
+ E_ROCKET
+ NL
+ E_CHECK + E_CHECK
+ WS1 + WS1
+ click.style(f"Starting hyperglass web server on", fg="green", bold=True) + click.style(msg_start, fg="green", bold=True)
+ WS1 + WS1
+ NL + click.style(msg_uri, fg="white")
+ E_SPARKLES + click.style(msg_host, fg="blue", bold=True)
+ NL
+ E_SPARKLES * 2
+ NL
+ E_SPARKLES * 3
+ NL
+ WS8
+ click.style("http://", fg="white")
+ click.style(str(params["host"]), fg="blue", bold=True)
+ click.style(CL, fg="white") + click.style(CL, fg="white")
+ click.style(str(params["port"]), fg="magenta", bold=True) + click.style(msg_port, fg="magenta", bold=True)
+ NL
+ WS4
+ E_ROCKET
+ NL
+ NL
+ WS1 + WS1
+ E_ROCKET + E_ROCKET
+ NL + NL
+ WS1
+ NL
) )
app.run(**params) uvicorn.run(app, **params)
except Exception as e: except Exception as e:
raise click.ClickException( raise click.ClickException(
click.style("✗ Failed to start test server: ", fg="red", bold=True) E_ERROR
+ click.style(e, fg="red") + WS1
) + click.style("Failed to start test server: ", fg="red", bold=True)
+ click.style(str(e), fg="red")
) from None
def write_env_variables(variables): def write_env_variables(variables):