diff --git a/hyperglass/cli/commands.py b/hyperglass/cli/commands.py index 834fc29..415f2b7 100644 --- a/hyperglass/cli/commands.py +++ b/hyperglass/cli/commands.py @@ -2,12 +2,10 @@ # Standard Library import sys -from getpass import getuser from pathlib import Path # Third Party -import inquirer -from click import group, option, confirm, help_option +from click import group, option, help_option # Project from hyperglass.util import cpu_count @@ -16,6 +14,7 @@ from hyperglass.util import cpu_count from .echo import error, label, success, cmd_help from .util import build_ui from .static import LABEL, CLI_HELP, E +from .installer import Installer from .formatting import HelpColorsGroup, HelpColorsCommand, random_colors # Define working directory @@ -98,12 +97,9 @@ def build_frontend(): ) def start(build, direct, workers): # noqa: C901 """Start web server and optionally build frontend assets.""" - try: - # Project - from hyperglass.api import start as uvicorn_start - from hyperglass.main import start - except ImportError as e: - error("Error importing hyperglass: {}", str(e)) + # Project + from hyperglass.api import start as uvicorn_start + from hyperglass.main import start kwargs = {} if workers != 0: @@ -121,10 +117,13 @@ def start(build, direct, workers): # noqa: C901 if not build and not direct: start(**kwargs) + elif not build and direct: uvicorn_start(**kwargs) + except KeyboardInterrupt: error("Stopping hyperglass due to keyboard interrupt.") + except BaseException as err: error(str(err)) @@ -167,65 +166,15 @@ def generate_secret(length): ) def setup(unattended): """Define application directory, move example files, generate systemd service.""" - # Project - from hyperglass.cli.util import ( - create_dir, - make_systemd, - write_to_file, - install_systemd, - migrate_static_assets, + + installer = Installer(unattended=unattended) + installer.install() + + success( + """Completed hyperglass installation. +After adding your hyperglass.yaml file, you should run the `hyperglass build-ui` command.""" # noqa: E501 ) - user_path = Path.home() / "hyperglass" - root_path = Path("/etc/hyperglass/") - - install_paths = [ - inquirer.List( - "install_path", - message="Choose a directory for hyperglass", - choices=[user_path, root_path], - ) - ] - if not unattended: - answer = inquirer.prompt(install_paths) - if answer is None: - error("A directory for hyperglass is required") - install_path = answer["install_path"] - - elif unattended: - install_path = user_path - - ui_dir = install_path / "static" / "ui" - images_dir = install_path / "static" / "images" - favicon_dir = images_dir / "favicons" - custom_dir = install_path / "static" / "custom" - - create_dir(install_path) - - for path in (ui_dir, images_dir, custom_dir, favicon_dir): - create_dir(path, parents=True) - - migrate_static_assets(install_path) - - if install_path == user_path: - user = getuser() - else: - user = "root" - - do_systemd = True - if not unattended and not confirm( - "Do you want to generate a systemd service file?" - ): - do_systemd = False - - if do_systemd: - systemd_file = install_path / "hyperglass.service" - systemd = make_systemd(user) - write_to_file(systemd_file, systemd) - install_systemd(install_path) - - build_ui() - @hg.command( "system-info", diff --git a/hyperglass/cli/installer.py b/hyperglass/cli/installer.py new file mode 100644 index 0000000..a9e2976 --- /dev/null +++ b/hyperglass/cli/installer.py @@ -0,0 +1,114 @@ +"""Install hyperglass.""" + +# Standard Library +import os +import shutil +from filecmp import dircmp +from pathlib import Path + +# Third Party +import inquirer + +# Local +from .echo import error, success, warning +from .util import create_dir + +USER_PATH = Path.home() / "hyperglass" +ROOT_PATH = Path("/etc/hyperglass/") +ASSET_DIR = Path(__file__).parent.parent / "images" +IGNORED_FILES = [".DS_Store"] + +INSTALL_PATHS = [ + inquirer.List( + "install_path", + message="Choose a directory for hyperglass", + choices=[USER_PATH, ROOT_PATH], + ) +] + + +def prompt_for_path() -> str: + """Recursively prompt the user for an app path until one is provided.""" + + answer = inquirer.prompt(INSTALL_PATHS) + + if answer is None: + warning("A directory for hyperglass is required") + answer = prompt_for_path() + + return answer["install_path"] + + +class Installer: + """Install hyperglass.""" + + def __init__(self, unattended: bool): + """Initialize installer.""" + + self.unattended = unattended + + def install(self) -> None: + """Complete the installation.""" + + self.app_path = self._get_app_path() + self._scaffold() + self._migrate_static_assets() + + def _get_app_path(self) -> Path: + """Find the app path from env variables or a prompt.""" + + if self.unattended: + return USER_PATH + + app_path = os.environ.get("HYPERGLASS_PATH", None) + + if app_path is None: + app_path = prompt_for_path() + + return app_path + + def _scaffold(self) -> None: + """Create the file structure necessary for hyperglass to run.""" + + ui_dir = self.app_path / "static" / "ui" + images_dir = self.app_path / "static" / "images" + favicon_dir = images_dir / "favicons" + custom_dir = self.app_path / "static" / "custom" + + create_dir(self.app_path) + + for path in (ui_dir, images_dir, favicon_dir, custom_dir): + create_dir(path, parents=True) + + def _migrate_static_assets(self) -> bool: + """Synchronize the project assets with the installation assets.""" + + target_dir = self.app_path / "static" / "images" + + if not target_dir.exists(): + shutil.copytree(ASSET_DIR, target_dir) + + # Compare the contents of the project's asset directory (considered + # the source of truth) with the installation directory. If they do + # not match, delete the installation directory's asset directory and + # re-copy it. + compare_initial = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) + + if not compare_initial.left_list == compare_initial.right_list: + shutil.rmtree(target_dir) + shutil.copytree(ASSET_DIR, target_dir) + + # Re-compare the source and destination directory contents to + # ensure they match. + compare_post = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) + + if not compare_post.left_list == compare_post.right_list: + error( + "Files in {a} do not match files in {b}", + a=str(ASSET_DIR), + b=str(target_dir), + ) + return False + + success("Migrated assets from {a} to {b}", a=str(ASSET_DIR), b=str(target_dir)) + return True diff --git a/hyperglass/cli/util.py b/hyperglass/cli/util.py index 87268f2..6bc2337 100644 --- a/hyperglass/cli/util.py +++ b/hyperglass/cli/util.py @@ -1,15 +1,14 @@ """CLI utility functions.""" + # Standard Library import os -import shutil -from typing import Iterable from pathlib import Path # Third Party from click import echo, style # Project -from hyperglass.cli.echo import info, error, status, success, warning +from hyperglass.cli.echo import info, error, status, success from hyperglass.cli.static import CL, NL, WS, E PROJECT_ROOT = Path(__file__).parent.parent @@ -30,50 +29,6 @@ def async_command(func): return update_wrapper(wrapper, func) -def fix_ownership(user, group, directory): - """Make user & group the owner of the directory.""" - # Standard Library - import os - import grp - import pwd - - uid = pwd.getpwnam(user).pw_uid - gid = grp.getgrnam(group).gr_gid - try: - for root, dirs, files in os.walk(directory): - for d in dirs: - full_path = os.path.join(root, d) - os.chown(full_path, uid, gid) - for f in files: - full_path = os.path.join(root, f) - os.chown(full_path, uid, gid) - os.chown(root, uid, gid) - except Exception as e: - error("Failed to change '{d}' ownership: {e}", d="hyperglass/", e=e) - - success("Successfully changed '{d}' ownership", d="hyperglass/") - - -def fix_permissions(directory): - """Make directory readable by public.""" - # Standard Library - import os - - try: - for root, dirs, files in os.walk(directory): - for d in dirs: - full_path = os.path.join(root, d) - os.chmod(full_path, 0o744) - for f in files: - full_path = os.path.join(root, f) - os.chmod(full_path, 0o744) - os.chmod(root, 0o744) - except Exception as e: - error("Failed to change '{d}' ownership: {e}", d="hyperglass/", e=e) - - success("Successfully changed '{d}' ownership", d="hyperglass/") - - def start_web_server(start, params): """Start web server.""" msg_start = "Starting hyperglass web server on" @@ -106,63 +61,6 @@ def start_web_server(start, params): error("Failed to start web server: {e}", e=e) -def migrate_config(config_dir): - """Copy example config files and remove .example extensions.""" - status("Migrating example config files...") - - # Standard Library - import shutil - - examples = Path(PROJECT_ROOT / "examples").glob("*.yaml.example") - - if not isinstance(config_dir, Path): - config_dir = Path(config_dir) - - if not config_dir.exists(): - error("'{d}' does not exist", d=str(config_dir)) - - migrated = 0 - for file in examples: - target_file = config_dir / file.with_suffix("").name - try: - if target_file.exists(): - info("{f} already exists", f=str(target_file)) - else: - shutil.copyfile(file, target_file) - migrated += 1 - info("Migrated {f}", f=str(target_file)) - except Exception as e: - error("Failed to migrate '{f}': {e}", f=str(target_file), e=e) - - if migrated == 0: - info("Migrated {n} example config files", n=migrated) - elif migrated > 0: - success("Successfully migrated {n} example config files", n=migrated) - - -def migrate_systemd(source, destination): - """Copy example systemd service file to /etc/systemd/system/.""" - # Standard Library - import os - import shutil - - basefile, extension = os.path.splitext(source) - newfile = os.path.join(destination, basefile) - - try: - status("Migrating example systemd service...") - - if os.path.exists(newfile): - info("'{f}' already exists", f=str(newfile)) - else: - shutil.copyfile(source, newfile) - - except Exception as e: - error("Error migrating example systemd service: {e}", e=e) - - success("Successfully migrated systemd service to: {f}", f=str(newfile)) - - def build_ui(): """Create a new UI build. @@ -236,109 +134,6 @@ def create_dir(path, **kwargs): return True -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 - """ - - if not isinstance(src, Path): - try: - src = Path(src) - except TypeError: - error("{p} is not a valid path", p=src) - if not isinstance(dst, Path): - try: - dst = Path(dst) - except TypeError: - error("{p} is not a valid path", p=dst) - - if not isinstance(files, Iterable): - 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(): - error("{p} does not exist", p=str(path)) - - migrated = 0 - - for file in files: - dst_file = dst / file.name - - if not file.exists(): - error("{f} does not exist", f=file) - - try: - if dst_file.exists(): - warning("{f} already exists", f=dst_file) - else: - shutil.copyfile(file, dst_file) - migrated += 1 - info("Migrated {f}", f=dst_file) - except Exception as e: - error("Failed to migrate {f}: {e}", f=dst_file, e=e) - - if migrated == 0: - warning("Migrated {n} files", n=migrated) - elif migrated > 0: - success("Successfully migrated {n} files", n=migrated) - return True - - -def make_systemd(user): - """Generate a systemd file based on the local system. - - Arguments: - user {str} -- User hyperglass needs to be run as - - Returns: - {str} -- Generated systemd template - """ - - # Third Party - import distro - - template = """ -[Unit] -Description=hyperglass -After=network.target -Requires={redis_name} - -[Service] -User={user} -Group={group} -ExecStart={hyperglass_path} start - -[Install] -WantedBy=multi-user.target - """ - known_rhel = ("rhel", "centos") - distro = distro.linux_distribution(full_distribution_name=False) - if distro[0] in known_rhel: - redis_name = "redis" - else: - redis_name = "redis-server" - - hyperglass_path = shutil.which("hyperglass") - - if not hyperglass_path: - hyperglass_path = "python3 -m hyperglass.console" - warning("hyperglass executable not found, using {h}", h=hyperglass_path) - - systemd = template.format( - redis_name=redis_name, user=user, group=user, hyperglass_path=hyperglass_path - ) - info(f"Generated systemd service:\n{systemd}") - return systemd - - def write_to_file(file, data): """Write string data to a file. @@ -365,47 +160,6 @@ def write_to_file(file, data): return True -def migrate_static_assets(app_path): - """Migrate app's static assets to app_path. - - Arguments: - app_path {Path} -- hyperglass runtime path - """ - # Project - from hyperglass.util import migrate_static_assets as _migrate - - migrated, msg, a, b = _migrate(app_path) - if not migrated: - callback = error - elif migrated: - callback = success - - callback(msg, a=a, b=b) - - -def install_systemd(app_path: Path) -> bool: - """Installs generated systemd file to system's systemd directory.""" - - service = app_path / "hyperglass.service" - systemd = Path("/etc/systemd/system") - installed = systemd / "hyperglass.service" - - if not systemd.exists(): - error("{e} does not exist. Unable to install systemd service.", e=systemd) - - try: - installed.symlink_to(service) - if not installed.exists(): - warning("Unable to symlink {s} to {d}", s=service, d=installed) - else: - success("Symlinked {s} to {d}", s=service, d=installed) - - except PermissionError: - warning("Permission denied to {f}. Systemd service not installed.", f=installed) - - return True - - def system_info(): """Create a markdown table of various system information. diff --git a/hyperglass/util/__init__.py b/hyperglass/util/__init__.py index b4e6a52..b928e76 100644 --- a/hyperglass/util/__init__.py +++ b/hyperglass/util/__init__.py @@ -258,44 +258,6 @@ async def move_files(src, dst, files): # noqa: C901 return migrated -def migrate_static_assets(app_path): - """Synchronize the project assets with the installation assets.""" - - # Standard Library - from filecmp import dircmp - - asset_dir = Path(__file__).parent.parent / "images" - target_dir = app_path / "static" / "images" - - target_exists = target_dir.exists() - - if not target_exists: - shutil.copytree(asset_dir, target_dir) - - # Compare the contents of the project's asset directory (considered - # the source of truth) with the installation directory. If they do - # not match, delete the installation directory's asset directory and - # re-copy it. - compare_initial = dircmp(asset_dir, target_dir, ignore=[".DS_Store"]) - - if not compare_initial.left_list == compare_initial.right_list: - shutil.rmtree(target_dir) - shutil.copytree(asset_dir, target_dir) - - # Re-compare the source and destination directory contents to - # ensure they match. - compare_post = dircmp(asset_dir, target_dir, ignore=[".DS_Store"]) - - if not compare_post.left_list == compare_post.right_list: - return ( - False, - "Files in {a} do not match files in {b}", - str(asset_dir), - str(target_dir), - ) - return (True, "Migrated assets from {a} to {b}", str(asset_dir), str(target_dir)) - - async def check_node_modules(): """Check if node_modules exists and has contents.