From 9d2cb72aa475f03d8ca85d54af69cc168a10d869 Mon Sep 17 00:00:00 2001 From: dw-0 Date: Wed, 1 May 2024 18:46:58 +0200 Subject: [PATCH] feat: implement crowsnest (#462) * feat: add crowsnest install/remove Signed-off-by: Dominik Willner * feat: add crowsnest update Signed-off-by: Dominik Willner --------- Signed-off-by: Dominik Willner --- kiauh/components/crowsnest/__init__.py | 13 ++ kiauh/components/crowsnest/crowsnest.py | 172 ++++++++++++++++++++++++ kiauh/core/menus/install_menu.py | 5 + kiauh/core/menus/main_menu.py | 12 +- kiauh/core/menus/remove_menu.py | 5 + kiauh/core/menus/update_menu.py | 15 ++- kiauh/utils/common.py | 16 +++ 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 kiauh/components/crowsnest/__init__.py create mode 100644 kiauh/components/crowsnest/crowsnest.py diff --git a/kiauh/components/crowsnest/__init__.py b/kiauh/components/crowsnest/__init__.py new file mode 100644 index 0000000..c68284b --- /dev/null +++ b/kiauh/components/crowsnest/__init__.py @@ -0,0 +1,13 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2024 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # + +from pathlib import Path + +CROWSNEST_DIR = Path.home().joinpath("crowsnest") +CROWSNEST_REPO = "https://github.com/mainsail-crew/crowsnest.git" diff --git a/kiauh/components/crowsnest/crowsnest.py b/kiauh/components/crowsnest/crowsnest.py new file mode 100644 index 0000000..62c51d4 --- /dev/null +++ b/kiauh/components/crowsnest/crowsnest.py @@ -0,0 +1,172 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2024 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +import shutil +import textwrap +from pathlib import Path +from subprocess import run, CalledProcessError +from typing import List, Dict, Literal, Union + +from components.crowsnest import CROWSNEST_REPO, CROWSNEST_DIR +from components.klipper.klipper import Klipper +from core.instance_manager.instance_manager import InstanceManager +from utils.common import get_install_status +from utils.constants import COLOR_CYAN, RESET_FORMAT, CURRENT_USER +from utils.git_utils import ( + git_clone_wrapper, + get_repo_name, + get_local_commit, + get_remote_commit, + git_pull_wrapper, +) +from utils.input_utils import get_confirm +from utils.logger import Logger +from utils.system_utils import ( + check_package_install, + install_system_packages, + parse_packages_from_file, + update_system_package_lists, +) + + +def install_crowsnest() -> None: + # Step 1: Clone crowsnest repo + git_clone_wrapper(CROWSNEST_REPO, "master", CROWSNEST_DIR) + + # Step 2: Install dependencies + requirements: List[str] = check_package_install(["make"]) + if requirements: + install_system_packages(requirements) + + # Step 3: Check for Multi Instance + im = InstanceManager(Klipper) + instances: List[Klipper] = im.find_instances() + + if len(instances) > 1: + Logger.print_status("Multi instance install detected ...") + info = textwrap.dedent(""" + Crowsnest is NOT designed to support multi instances. + A workaround for this is to choose the most used instance as a 'master' + Use this instance to set up your 'crowsnest.conf' and steering it's service. + Found the following instances: + """)[:-1] + print(info, end="") + for instance in instances: + print(f"● {instance.data_dir_name}") + + Logger.print_status("\nLaunching crowsnest's configuration tool ...") + + if not get_confirm("Continue with configuration?", False, allow_go_back=True): + Logger.print_info("Installation aborted by user ... Exiting!") + return + + config = Path(CROWSNEST_DIR).joinpath("tools/.config") + try: + run( + "make config", + cwd=CROWSNEST_DIR, + shell=True, + check=True, + ) + except CalledProcessError as e: + Logger.print_error(f"Something went wrong! Please try again...\n{e}") + if config.exists(): + Path.unlink(config) + return + + if not config.exists(): + Logger.print_error("Generating .config failed, installation aborted") + return + + # Step 4: Launch crowsnest installer + print(f"{COLOR_CYAN}Installer will prompt you for sudo password!{RESET_FORMAT}") + Logger.print_status("Launching crowsnest installer ...") + try: + run( + f"sudo make install BASE_USER={CURRENT_USER}", + cwd=CROWSNEST_DIR, + shell=True, + check=True, + ) + except CalledProcessError as e: + Logger.print_error(f"Something went wrong! Please try again...\n{e}") + return + + +def update_crowsnest() -> None: + try: + stop_cn = "sudo systemctl stop crowsnest" + restart_cn = "sudo systemctl restart crowsnest" + + Logger.print_status("Stopping Crowsnest service ...") + run(stop_cn, shell=True, check=True) + + if not CROWSNEST_DIR.exists(): + git_clone_wrapper(CROWSNEST_REPO, "master", CROWSNEST_DIR) + else: + Logger.print_status("Updating Crowsnest ...") + + git_pull_wrapper(CROWSNEST_REPO, CROWSNEST_DIR) + + script = CROWSNEST_DIR.joinpath("tools/install.sh") + deps = parse_packages_from_file(script) + packages = check_package_install(deps) + update_system_package_lists(silent=False) + install_system_packages(packages) + + Logger.print_status("Restarting Crowsnest service ...") + run(restart_cn, shell=True, check=True) + + Logger.print_ok("Crowsnest updated successfully.", end="\n\n") + except CalledProcessError as e: + Logger.print_error(f"Something went wrong! Please try again...\n{e}") + return + + +def get_crowsnest_status() -> ( + Dict[ + Literal["status", "status_code", "repo", "local", "remote"], + Union[str, int], + ] +): + files = [ + Path("/usr/local/bin/crowsnest"), + Path("/etc/logrotate.d/crowsnest"), + Path("/etc/systemd/system/crowsnest.service"), + ] + status = get_install_status(CROWSNEST_DIR, files) + return { + "status": status.get("status"), + "status_code": status.get("status_code"), + "repo": get_repo_name(CROWSNEST_DIR), + "local": get_local_commit(CROWSNEST_DIR), + "remote": get_remote_commit(CROWSNEST_DIR), + } + + +def remove_crowsnest() -> None: + if not CROWSNEST_DIR.exists(): + Logger.print_info("Crowsnest does not seem to be installed! Skipping ...") + return + + try: + run( + "make uninstall", + cwd=CROWSNEST_DIR, + shell=True, + check=True, + ) + except CalledProcessError as e: + Logger.print_error(f"Something went wrong! Please try again...\n{e}") + return + + Logger.print_status("Removing crowsnest directory ...") + shutil.rmtree(CROWSNEST_DIR) + Logger.print_ok("Directory removed!") diff --git a/kiauh/core/menus/install_menu.py b/kiauh/core/menus/install_menu.py index ce42419..4d69b9e 100644 --- a/kiauh/core/menus/install_menu.py +++ b/kiauh/core/menus/install_menu.py @@ -10,6 +10,7 @@ import textwrap from typing import Type, Optional +from components.crowsnest.crowsnest import install_crowsnest from components.klipper import klipper_setup from components.moonraker import moonraker_setup from components.webui_client import client_setup @@ -44,6 +45,7 @@ class InstallMenu(BaseMenu): "4": Option(method=self.install_fluidd, menu=False), "5": Option(method=self.install_mainsail_config, menu=False), "6": Option(method=self.install_fluidd_config, menu=False), + "9": Option(method=self.install_crowsnest, menu=False), } def print_menu(self): @@ -88,3 +90,6 @@ class InstallMenu(BaseMenu): def install_fluidd_config(self, **kwargs): client_config_setup.install_client_config(FluiddData()) + + def install_crowsnest(self, **kwargs): + install_crowsnest() diff --git a/kiauh/core/menus/main_menu.py b/kiauh/core/menus/main_menu.py index 35291bc..5e8464c 100644 --- a/kiauh/core/menus/main_menu.py +++ b/kiauh/core/menus/main_menu.py @@ -10,6 +10,7 @@ import textwrap from typing import Type, Optional +from components.crowsnest.crowsnest import get_crowsnest_status from components.klipper.klipper_utils import get_klipper_status from components.log_uploads.menus.log_upload_menu import LogUploadMenu from components.moonraker.moonraker_utils import get_moonraker_status @@ -90,16 +91,23 @@ class MainMenu(BaseMenu): self.ms_status = get_client_status(MainsailData()) self.fl_status = get_client_status(FluiddData()) self.cc_status = get_current_client_config([MainsailData(), FluiddData()]) + self._update_status("cn", get_crowsnest_status) def _update_status(self, status_name: str, status_fn: callable) -> None: status_data = status_fn() status = status_data.get("status") code = status_data.get("status_code") - instances = f" {status_data.get('instances')}" if code == 1 else "" + + instance_count = status_data.get("instances") + + count: str = "" + if instance_count and code == 1: + count = f" {instance_count}" + setattr( self, f"{status_name}_status", - self._format_status_by_code(code, status, instances), + self._format_status_by_code(code, status, count), ) setattr( self, diff --git a/kiauh/core/menus/remove_menu.py b/kiauh/core/menus/remove_menu.py index d7ce2c0..b6f11b0 100644 --- a/kiauh/core/menus/remove_menu.py +++ b/kiauh/core/menus/remove_menu.py @@ -10,6 +10,7 @@ import textwrap from typing import Type, Optional +from components.crowsnest.crowsnest import remove_crowsnest from components.klipper.menus.klipper_remove_menu import KlipperRemoveMenu from components.moonraker.menus.moonraker_remove_menu import ( MoonrakerRemoveMenu, @@ -42,6 +43,7 @@ class RemoveMenu(BaseMenu): "2": Option(method=self.remove_moonraker, menu=True), "3": Option(method=self.remove_mainsail, menu=True), "4": Option(method=self.remove_fluidd, menu=True), + "6": Option(method=self.remove_crowsnest, menu=True), } def print_menu(self): @@ -78,3 +80,6 @@ class RemoveMenu(BaseMenu): def remove_fluidd(self, **kwargs): ClientRemoveMenu(previous_menu=self.__class__, client=FluiddData()).run() + + def remove_crowsnest(self, **kwargs): + remove_crowsnest() diff --git a/kiauh/core/menus/update_menu.py b/kiauh/core/menus/update_menu.py index 3af9085..4a4b236 100644 --- a/kiauh/core/menus/update_menu.py +++ b/kiauh/core/menus/update_menu.py @@ -10,6 +10,7 @@ import textwrap from typing import Type, Optional +from components.crowsnest.crowsnest import get_crowsnest_status, update_crowsnest from components.klipper.klipper_setup import update_klipper from components.klipper.klipper_utils import ( get_klipper_status, @@ -57,6 +58,8 @@ class UpdateMenu(BaseMenu): self.mc_remote = f"{COLOR_WHITE}{RESET_FORMAT}" self.fc_local = f"{COLOR_WHITE}{RESET_FORMAT}" self.fc_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + self.cn_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.cn_remote = f"{COLOR_WHITE}{RESET_FORMAT}" self.mainsail_client = MainsailData() self.fluidd_client = FluiddData() @@ -111,7 +114,7 @@ class UpdateMenu(BaseMenu): | Other: |---------------|---------------| | 7) KlipperScreen | | | | 8) Mobileraker | | | - | 9) Crowsnest | | | + | 9) Crowsnest | {self.cn_local:<22} | {self.cn_remote:<22} | | |-------------------------------| | 10) System | | """ @@ -143,7 +146,8 @@ class UpdateMenu(BaseMenu): def update_mobileraker(self, **kwargs): ... - def update_crowsnest(self, **kwargs): ... + def update_crowsnest(self, **kwargs): + update_crowsnest() def upgrade_system_packages(self, **kwargs): ... @@ -189,6 +193,13 @@ class UpdateMenu(BaseMenu): ) self.fc_remote = f"{COLOR_GREEN}{fc_status.get('remote')}{RESET_FORMAT}" + # crowsnest + cn_status = get_crowsnest_status() + self.cn_local = self.format_local_status( + cn_status.get("local"), cn_status.get("remote") + ) + self.cn_remote = f"{COLOR_GREEN}{cn_status.get('remote')}{RESET_FORMAT}" + def format_local_status(self, local_version, remote_version) -> str: if local_version == remote_version: return f"{COLOR_GREEN}{local_version}{RESET_FORMAT}" diff --git a/kiauh/utils/common.py b/kiauh/utils/common.py index 89b2ec5..307d6a8 100644 --- a/kiauh/utils/common.py +++ b/kiauh/utils/common.py @@ -55,6 +55,22 @@ def check_install_dependencies(deps: List[str]) -> None: install_system_packages(requirements) +def get_install_status( + repo_dir: Path, opt_files: List[Path] +) -> Dict[Literal["status", "status_code", "instances"], Union[str, int]]: + status = [repo_dir.exists()] + + for f in opt_files: + status.append(f.exists()) + + if all(status): + return {"status": "Installed!", "status_code": 1} + elif not any(status): + return {"status": "Not installed!", "status_code": 2} + else: + return {"status": "Incomplete!", "status_code": 3} + + def get_install_status_common( instance_type: Type[BaseInstance], repo_dir: Path, env_dir: Path ) -> Dict[Literal["status", "status_code", "instances"], Union[str, int]]: