mirror of
https://github.com/checktheroads/hyperglass
synced 2024-05-11 05:55:08 +00:00
384 lines
11 KiB
Python
384 lines
11 KiB
Python
"""Utility functions."""
|
|
|
|
|
|
def _logger():
|
|
from loguru import logger as _loguru_logger
|
|
from hyperglass.constants import LOG_HANDLER
|
|
from hyperglass.constants import LOG_LEVELS
|
|
|
|
_loguru_logger.remove()
|
|
_loguru_logger.configure(handlers=[LOG_HANDLER], levels=LOG_LEVELS)
|
|
return _loguru_logger
|
|
|
|
|
|
log = _logger()
|
|
|
|
|
|
def cpu_count():
|
|
"""Get server's CPU core count.
|
|
|
|
Used for number of web server workers.
|
|
|
|
Returns:
|
|
{int} -- CPU Cores
|
|
"""
|
|
import multiprocessing
|
|
|
|
return multiprocessing.cpu_count()
|
|
|
|
|
|
async def check_path(path, mode="r"):
|
|
"""Verify if a path exists and is accessible.
|
|
|
|
Arguments:
|
|
path {Path|str} -- Path object or string of path
|
|
mode {str} -- File mode, r or w
|
|
|
|
Raises:
|
|
RuntimeError: Raised if file does not exist or is not accessible
|
|
|
|
Returns:
|
|
{Path|None} -- Path object if checks pass, None if not.
|
|
"""
|
|
from pathlib import Path
|
|
from aiofile import AIOFile
|
|
|
|
try:
|
|
if not isinstance(path, Path):
|
|
path = Path(path)
|
|
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"{str(path)} does not exist.")
|
|
|
|
async with AIOFile(path, mode):
|
|
result = path
|
|
|
|
except Exception:
|
|
result = None
|
|
|
|
return result
|
|
|
|
|
|
def check_python():
|
|
"""Verify Python Version.
|
|
|
|
Raises:
|
|
RuntimeError: Raised if running Python version is invalid.
|
|
|
|
Returns:
|
|
{str} -- Python version
|
|
"""
|
|
import sys
|
|
from hyperglass.constants import MIN_PYTHON_VERSION
|
|
|
|
pretty_version = ".".join(tuple(str(v) for v in MIN_PYTHON_VERSION))
|
|
if sys.version_info < MIN_PYTHON_VERSION:
|
|
raise RuntimeError(f"Python {pretty_version}+ is required.")
|
|
return pretty_version
|
|
|
|
|
|
async def build_ui(app_path):
|
|
"""Execute `next build` & `next export` from UI directory.
|
|
|
|
Raises:
|
|
RuntimeError: Raised if exit code is not 0.
|
|
RuntimeError: Raised when any other error occurs.
|
|
"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
ui_dir = Path(__file__).parent.parent / "ui"
|
|
build_dir = app_path / "static" / "ui"
|
|
|
|
build_command = "node_modules/.bin/next build"
|
|
export_command = "node_modules/.bin/next export -o {f}".format(f=build_dir)
|
|
|
|
all_messages = []
|
|
for command in (build_command, export_command):
|
|
try:
|
|
proc = await asyncio.create_subprocess_shell(
|
|
cmd=command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=ui_dir,
|
|
)
|
|
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
|
messages = stdout.decode("utf-8").strip()
|
|
errors = stderr.decode("utf-8").strip()
|
|
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"\nMessages:\n{messages}\nErrors:\n{errors}")
|
|
|
|
await proc.wait()
|
|
all_messages.append(messages)
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(str(e))
|
|
|
|
return "\n".join(all_messages)
|
|
|
|
|
|
async def write_env(variables):
|
|
"""Write environment variables to temporary JSON file.
|
|
|
|
Arguments:
|
|
variables {dict} -- Environment variables to write.
|
|
|
|
Raises:
|
|
RuntimeError: Raised on any errors.
|
|
"""
|
|
from aiofile import AIOFile
|
|
import ujson as json
|
|
from pathlib import Path
|
|
|
|
env_file = Path("/tmp/hyperglass.env.json") # noqa: S108
|
|
env_vars = json.dumps(variables)
|
|
|
|
try:
|
|
async with AIOFile(env_file, "w+") as ef:
|
|
await ef.write(env_vars)
|
|
await ef.fsync()
|
|
except Exception as e:
|
|
raise RuntimeError(str(e))
|
|
|
|
return f"Wrote {env_vars} to {str(env_file)}"
|
|
|
|
|
|
async def check_redis(db, config):
|
|
"""Ensure Redis is running before starting server.
|
|
|
|
Arguments:
|
|
db {int} -- Redis database ID
|
|
config {dict} -- Redis configuration parameters
|
|
|
|
Raises:
|
|
RuntimeError: Raised if Redis is not running.
|
|
|
|
Returns:
|
|
{bool} -- True if redis is running.
|
|
"""
|
|
import aredis
|
|
|
|
redis_instance = aredis.StrictRedis(db=db, **config)
|
|
redis_host = config["host"]
|
|
redis_port = config["port"]
|
|
try:
|
|
await redis_instance.echo("hyperglass test")
|
|
except Exception:
|
|
raise RuntimeError(
|
|
f"Redis isn't running at: {redis_host}:{redis_port}"
|
|
) from None
|
|
return True
|
|
|
|
|
|
async def clear_redis_cache(db, config):
|
|
"""Clear the Redis cache.
|
|
|
|
Arguments:
|
|
db {int} -- Redis database ID
|
|
config {dict} -- Redis configuration parameters
|
|
|
|
Raises:
|
|
RuntimeError: Raised if clearing the cache produces an error.
|
|
|
|
Returns:
|
|
{bool} -- True if cache was cleared.
|
|
"""
|
|
import aredis
|
|
|
|
try:
|
|
redis_instance = aredis.StrictRedis(db=db, **config)
|
|
await redis_instance.flushdb()
|
|
except Exception as e:
|
|
raise RuntimeError(f"Error clearing cache: {str(e)}") from None
|
|
return True
|
|
|
|
|
|
async def move_files(src, dst, files): # noqa: C901
|
|
"""Move iterable of files from source to destination.
|
|
|
|
Arguments:
|
|
src {Path} -- Current directory of files
|
|
dst {Path} -- Target destination directory
|
|
files {Iterable} -- Iterable of files
|
|
"""
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
def error(*args, **kwargs):
|
|
msg = ", ".join(args)
|
|
kwargs = {k: str(v) for k, v in kwargs.items()}
|
|
error_msg = msg.format(**kwargs)
|
|
log.error(error_msg)
|
|
return RuntimeError(error_msg)
|
|
|
|
if not isinstance(src, Path):
|
|
try:
|
|
src = Path(src)
|
|
except TypeError:
|
|
raise error("{p} is not a valid path", p=src)
|
|
|
|
if not isinstance(dst, Path):
|
|
try:
|
|
dst = Path(dst)
|
|
except TypeError:
|
|
raise error("{p} is not a valid path", p=dst)
|
|
|
|
if not isinstance(files, Iterable):
|
|
raise error(
|
|
"{fa} must be an iterable (list, tuple, or generator). Received {f}",
|
|
fa="Files argument",
|
|
f=files,
|
|
)
|
|
|
|
for path in (src, dst):
|
|
if not path.exists():
|
|
raise error("{p} does not exist", p=path)
|
|
|
|
migrated = ()
|
|
|
|
for file in files:
|
|
dst_file = dst / file.name
|
|
|
|
if not file.exists():
|
|
raise error("{f} does not exist", f=file)
|
|
|
|
try:
|
|
if not dst_file.exists():
|
|
shutil.copyfile(file, dst_file)
|
|
migrated += (str(dst_file),)
|
|
except Exception as e:
|
|
raise error("Failed to migrate {f}: {e}", f=dst_file, e=e)
|
|
|
|
return migrated
|
|
|
|
|
|
async def build_frontend( # noqa: C901
|
|
dev_mode, dev_url, prod_url, params, app_path, force=False
|
|
):
|
|
"""Perform full frontend UI build process.
|
|
|
|
Securely creates temporary file, writes frontend configuration
|
|
parameters to file as JSON. Then writes the name of the temporary
|
|
file to /tmp/hyperglass.env.json as {"configFile": <file_name> }.
|
|
|
|
Webpack reads /tmp/hyperglass.env.json, loads the temporary file,
|
|
and sets its contents to Node environment variables during the build
|
|
process.
|
|
|
|
After the build is successful, the temporary file is automatically
|
|
closed during garbage collection.
|
|
|
|
Arguments:
|
|
dev_mode {bool} -- Development Mode
|
|
dev_url {str} -- Development Mode URL
|
|
prod_url {str} -- Production Mode URL
|
|
params {dict} -- Frontend Config paramters
|
|
|
|
Raises:
|
|
RuntimeError: Raised if errors occur during build process.
|
|
|
|
Returns:
|
|
{bool} -- True if successful
|
|
"""
|
|
import hashlib
|
|
import tempfile
|
|
import shutil
|
|
from filecmp import dircmp
|
|
from pathlib import Path
|
|
from aiofile import AIOFile
|
|
import ujson as json
|
|
|
|
env_file = Path("/tmp/hyperglass.env.json") # noqa: S108
|
|
|
|
env_vars = {"_HYPERGLASS_CONFIG_": params}
|
|
|
|
"""
|
|
Set NextJS production/development mode and base URL based on
|
|
developer_mode setting.
|
|
"""
|
|
if dev_mode:
|
|
env_vars.update({"NODE_ENV": "development", "_HYPERGLASS_URL_": dev_url})
|
|
else:
|
|
env_vars.update({"NODE_ENV": "production", "_HYPERGLASS_URL_": prod_url})
|
|
|
|
try:
|
|
env_json = json.dumps(env_vars)
|
|
|
|
"""
|
|
Create SHA256 hash from all parameters passed to UI, use as
|
|
build identifier.
|
|
"""
|
|
build_id = hashlib.sha256(env_json.encode()).hexdigest()
|
|
|
|
if env_file.exists() and not force:
|
|
"""
|
|
Read hard-coded environment file from last build. If build
|
|
ID matches this build's ID, don't run a new build.
|
|
"""
|
|
async with AIOFile(env_file, "r") as ef:
|
|
ef_json = await ef.read()
|
|
ef_id = json.loads(ef_json).get("buildId", "empty")
|
|
|
|
log.debug("Previous Build ID: {id}", id=ef_id)
|
|
|
|
if ef_id == build_id:
|
|
log.debug(
|
|
"UI parameters unchanged since last build, skipping UI build..."
|
|
)
|
|
return True
|
|
|
|
"""
|
|
Create temporary file. json file extension is added for easy
|
|
webpack JSON parsing.
|
|
"""
|
|
temp_file = tempfile.NamedTemporaryFile(
|
|
mode="w+", prefix="hyperglass_", suffix=".json", delete=not dev_mode
|
|
)
|
|
log.debug(
|
|
f"Created temporary UI config file: '{temp_file.name}' for build {build_id}"
|
|
)
|
|
|
|
async with AIOFile(temp_file.name, "w+") as temp:
|
|
await temp.write(env_json)
|
|
await temp.fsync()
|
|
|
|
# Write "permanent" file (hard-coded named) for Node to read.
|
|
async with AIOFile(env_file, "w+") as ef:
|
|
await ef.write(
|
|
json.dumps({"configFile": temp_file.name, "buildId": build_id})
|
|
)
|
|
await ef.fsync()
|
|
|
|
# While temporary file is still open, initiate UI build process.
|
|
if not dev_mode or force:
|
|
build_result = await build_ui(app_path=app_path)
|
|
|
|
if build_result:
|
|
log.debug("Completed UI build")
|
|
elif dev_mode and not force:
|
|
log.debug("Running in developer mode, did not build new UI files")
|
|
|
|
"""
|
|
Compare repository's static assets with build directory's
|
|
assets. If the contents don't match, re-copy the files.
|
|
"""
|
|
asset_dir = Path(__file__).parent.parent / "assets"
|
|
target_dir = app_path / "static" / "images"
|
|
comparison = dircmp(asset_dir, target_dir, ignore=[".DS_Store"])
|
|
|
|
if not comparison.left_list == comparison.right_list:
|
|
shutil.copytree(asset_dir, target_dir)
|
|
if not comparison.left_list == comparison.right_list:
|
|
raise Exception(
|
|
"Files in '{a}' do not match files in '{b}'".format(
|
|
a=str(asset_dir), b=str(target_dir)
|
|
)
|
|
)
|
|
except Exception as e:
|
|
raise RuntimeError(str(e))
|
|
|
|
return True
|