From ed44052f9310f52b1f56a2ed1a5b8e84c5ccfb63 Mon Sep 17 00:00:00 2001 From: MRColor Date: Thu, 12 Dec 2024 13:32:18 +0100 Subject: [PATCH] feat: added healthcheck spinner for docker, env nad dashboard urls steps --- config/m4b-config.json | 2 +- utils/generator.py | 376 ++++++++++++++++++++++------------------- utils/helper.py | 25 +++ 3 files changed, 228 insertions(+), 175 deletions(-) diff --git a/config/m4b-config.json b/config/m4b-config.json index 7946f31..2553bfd 100644 --- a/config/m4b-config.json +++ b/config/m4b-config.json @@ -1,6 +1,6 @@ { "project": { - "project_version": "4.5.2", + "project_version": "4.5.3", "compose_project_name": "money4band", "ds_project_server_url": "https://discord.com/invite/Fq8eeazBAD" }, diff --git a/utils/generator.py b/utils/generator.py index 74ec887..57aa03c 100644 --- a/utils/generator.py +++ b/utils/generator.py @@ -9,6 +9,7 @@ import yaml # Import PyYAML import secrets import getpass +import threading script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.dirname(script_dir) @@ -19,6 +20,7 @@ from utils.detector import detect_architecture from utils.loader import load_json_config from utils.dumper import write_json +from utils.helper import show_spinner def validate_uuid(uuid: str, length: int) -> bool: @@ -64,98 +66,106 @@ def assemble_docker_compose(m4b_config_path_or_dict: Any, app_config_path_or_dic Raises: Exception: If an error occurs during the assembly process. """ - m4b_config = load_json_config(m4b_config_path_or_dict) - app_config = load_json_config(app_config_path_or_dict) - user_config = load_json_config(user_config_path_or_dict) - - default_docker_platform = m4b_config['system'].get('default_docker_platform', 'linux/amd64') - proxy_enabled = user_config['proxies'].get('enabled', False) - - services = {} - apps_categories = ['apps'] - apps_categories.append('extra-apps') # Overrides extra apps exclusion from m4b proxies instances - if is_main_instance: - apps_categories.append('extra-apps') - - for category in apps_categories: - for app in app_config.get(category, []): - app_name = app['name'].lower() - user_app_config = user_config['apps'].get(app_name, {}) - if user_app_config.get('enabled'): - app_compose_config = app['compose_config'] - image = app_compose_config['image'] - image_name, image_tag = image.split(':') - docker_platform = user_app_config.get('docker_platform', default_docker_platform) - - if not check_img_arch_support(image_name, image_tag, docker_platform): - compatible_tag = get_compatible_tag(image_name, docker_platform) - if compatible_tag: - app_compose_config['image'] = f"{image_name}:{compatible_tag}" - # Add platform also on all already compatible images tags - app_compose_config['platform'] = docker_platform - logging.info(f"Updated {app_name} to compatible tag: {compatible_tag}") - else: - logging.warning(f"No compatible tag found for {image_name} with architecture {docker_platform}. Searching for a suitable tag for default emulation architecture {default_docker_platform}.") - # find a compatibile tag with default docker platform - compatible_tag = get_compatible_tag(image_name, default_docker_platform) + event = threading.Event() + spinner_thread = threading.Thread(target=show_spinner, args=("Assembling Docker Compose file...", event)) + spinner_thread.start() + + try: + m4b_config = load_json_config(m4b_config_path_or_dict) + app_config = load_json_config(app_config_path_or_dict) + user_config = load_json_config(user_config_path_or_dict) + + default_docker_platform = m4b_config['system'].get('default_docker_platform', 'linux/amd64') + proxy_enabled = user_config['proxies'].get('enabled', False) + + services = {} + apps_categories = ['apps'] + apps_categories.append('extra-apps') # Overrides extra apps exclusion from m4b proxies instances + if is_main_instance: + apps_categories.append('extra-apps') + + for category in apps_categories: + for app in app_config.get(category, []): + app_name = app['name'].lower() + user_app_config = user_config['apps'].get(app_name, {}) + if user_app_config.get('enabled'): + app_compose_config = app['compose_config'] + image = app_compose_config['image'] + image_name, image_tag = image.split(':') + docker_platform = user_app_config.get('docker_platform', default_docker_platform) + + if not check_img_arch_support(image_name, image_tag, docker_platform): + compatible_tag = get_compatible_tag(image_name, docker_platform) if compatible_tag: app_compose_config['image'] = f"{image_name}:{compatible_tag}" - # Add platform to the compose configuration to force image pull for emulation - app_compose_config['platform'] = default_docker_platform - logging.warning(f"Compatible tag found to run {image_name} with emulation on {default_docker_platform} architecture. Using binfmt emulation for {app_name} with image {image_name}:{image_tag}") + # Add platform also on all already compatible images tags + app_compose_config['platform'] = docker_platform + logging.info(f"Updated {app_name} to compatible tag: {compatible_tag}") else: - logging.error(f"No compatible tag found for {image_name} with default architecture {default_docker_platform}.") - logging.error(f"Please check the image tag and architecture compatibility on the registry. Skipping {app_name}...") - continue # Do not add the app to the compose file - else: - app_compose_config['platform'] = docker_platform # Add platform also on all already compatible images tags - if proxy_enabled: - app_proxy_compose = app.get('compose_config_proxy', {}) - for key, value in app_proxy_compose.items(): - app_compose_config[key] = value - if app_compose_config[key] is None: - del app_compose_config[key] - - services[app_name] = app_compose_config - - # Add common services only if this is the main instance - compose_config_common = user_config.get('compose_config_common', {}) - if is_main_instance: - watchtower_service_key = 'proxy_enabled' if proxy_enabled else 'proxy_disabled' - watchtower_service = compose_config_common['watchtower_service'][watchtower_service_key] - services['watchtower'] = watchtower_service - services['m4bwebdashboard'] = compose_config_common['m4b_dashboard_service'] - if proxy_enabled: - services['proxy'] = compose_config_common['proxy_service'] - - # Define network configuration using config json and environment variables - # This is a hybrid solution to remember that it could be possible to ditch the env file and generate all compose file parts from config json - network_config = { - 'networks': { - 'default': { - 'driver': compose_config_common['network']['driver'], - 'ipam': { - 'config': [ - { - 'subnet': f"{compose_config_common['network']['subnet']}/{compose_config_common['network']['netmask']}" - } - ] + logging.warning(f"No compatible tag found for {image_name} with architecture {docker_platform}. Searching for a suitable tag for default emulation architecture {default_docker_platform}.") + # find a compatibile tag with default docker platform + compatible_tag = get_compatible_tag(image_name, default_docker_platform) + if compatible_tag: + app_compose_config['image'] = f"{image_name}:{compatible_tag}" + # Add platform to the compose configuration to force image pull for emulation + app_compose_config['platform'] = default_docker_platform + logging.warning(f"Compatible tag found to run {image_name} with emulation on {default_docker_platform} architecture. Using binfmt emulation for {app_name} with image {image_name}:{image_tag}") + else: + logging.error(f"No compatible tag found for {image_name} with default architecture {default_docker_platform}.") + logging.error(f"Please check the image tag and architecture compatibility on the registry. Skipping {app_name}...") + continue # Do not add the app to the compose file + else: + app_compose_config['platform'] = docker_platform # Add platform also on all already compatible images tags + if proxy_enabled: + app_proxy_compose = app.get('compose_config_proxy', {}) + for key, value in app_proxy_compose.items(): + app_compose_config[key] = value + if app_compose_config[key] is None: + del app_compose_config[key] + + services[app_name] = app_compose_config + + # Add common services only if this is the main instance + compose_config_common = user_config.get('compose_config_common', {}) + if is_main_instance: + watchtower_service_key = 'proxy_enabled' if proxy_enabled else 'proxy_disabled' + watchtower_service = compose_config_common['watchtower_service'][watchtower_service_key] + services['watchtower'] = watchtower_service + services['m4bwebdashboard'] = compose_config_common['m4b_dashboard_service'] + if proxy_enabled: + services['proxy'] = compose_config_common['proxy_service'] + + # Define network configuration using config json and environment variables + # This is a hybrid solution to remember that it could be possible to ditch the env file and generate all compose file parts from config json + network_config = { + 'networks': { + 'default': { + 'driver': compose_config_common['network']['driver'], + 'ipam': { + 'config': [ + { + 'subnet': f"{compose_config_common['network']['subnet']}/{compose_config_common['network']['netmask']}" + } + ] + } } } } - } - # Create the compose dictionary - compose_dict = { - 'services': services - } + # Create the compose dictionary + compose_dict = { + 'services': services + } - # Append network configuration at the bottom - compose_dict.update(network_config) + # Append network configuration at the bottom + compose_dict.update(network_config) - with open(compose_output_path, 'w') as f: - yaml.dump(compose_dict, f, sort_keys=False, default_flow_style=False) - logging.info(f"Docker Compose file assembled and saved to {compose_output_path}") + with open(compose_output_path, 'w') as f: + yaml.dump(compose_dict, f, sort_keys=False, default_flow_style=False) + logging.info(f"Docker Compose file assembled and saved to {compose_output_path}") + finally: + event.set() + spinner_thread.join() def generate_env_file(m4b_config_path_or_dict: Any, app_config_path_or_dict: Any, user_config_path_or_dict: Any, env_output_path: str = str(os.path.join(os.getcwd(), '.env')), is_main_instance: bool = False) -> None: @@ -172,72 +182,81 @@ def generate_env_file(m4b_config_path_or_dict: Any, app_config_path_or_dict: Any Raises: Exception: If an error occurs during the file generation process. """ - m4b_config = load_json_config(m4b_config_path_or_dict) - app_config = load_json_config(app_config_path_or_dict) - user_config = load_json_config(user_config_path_or_dict) - - env_lines = [] - - # Add project and system configurations - project_config = m4b_config.get('project', {}) - for key, value in project_config.items(): - env_lines.append(f"{key.upper()}={value}") - - # Add resource limits configurations - resource_limits_config = user_config.get('resource_limits', {}) - for key, value in resource_limits_config.items(): - env_lines.append(f"{key.upper()}={value}") - - # Add network configurations - network_config = m4b_config.get('network', {}) - for key, value in network_config.items(): - env_lines.append(f"NETWORK_{key.upper()}={value}") - - # Add user and device configurations - device_info = user_config.get('device_info', {}) - for key, value in device_info.items(): - env_lines.append(f"{key.upper()}={value}") - - # Add m4b_dashboard configurations - m4b_dashboard_config = user_config.get('m4b_dashboard', {}) - for key, value in m4b_dashboard_config.items(): - env_lines.append(f"M4B_DASHBOARD_{key.upper()}={value}") - - # Add proxy configurations - proxy_config = user_config.get('proxies', {}) - for key, value in proxy_config.items(): - env_lines.append(f"STACK_PROXY_{key.upper()}={value}") - - # Add notification configurations if enabled - notifications_config = user_config.get('notifications', {}) - if notifications_config.get('enabled'): - for key, value in notifications_config.items(): - env_lines.append(f"WATCHTOWER_NOTIFICATION_{key.upper()}={value}") - - # Add app-specific configurations only if the app is enabled - apps_categories = ['apps'] - if is_main_instance: - apps_categories.append('extra-apps') - for category in apps_categories: - for app in app_config.get(category, []): - app_name = app['name'].upper() - app_flags = app.get('flags', {}) - app_user_config = user_config['apps'].get(app['name'].lower(), {}) - if app_user_config.get('enabled', False): - for flag_name in app_flags.keys(): - if flag_name in app_user_config: - env_var_name = f"{app_name}_{flag_name.upper()}" - env_var_value = app_user_config[flag_name] - env_lines.append(f"{env_var_name}={env_var_value}") - - # Add app dashboard port if it exists - if 'dashboard_port' in app_user_config: - env_lines.append(f"{app_name.upper()}_DASHBOARD_PORT={app_user_config['dashboard_port']}") - - # Write to .env file - with open(env_output_path, 'w') as f: - f.write('\n'.join(env_lines)) - logging.info(f".env file generated and saved to {env_output_path}") + event = threading.Event() + spinner_thread = threading.Thread(target=show_spinner, args=("Generating .env file...", event)) + spinner_thread.start() + + try: + m4b_config = load_json_config(m4b_config_path_or_dict) + app_config = load_json_config(app_config_path_or_dict) + user_config = load_json_config(user_config_path_or_dict) + + env_lines = [] + + # Add project and system configurations + project_config = m4b_config.get('project', {}) + for key, value in project_config.items(): + env_lines.append(f"{key.upper()}={value}") + + # Add resource limits configurations + resource_limits_config = user_config.get('resource_limits', {}) + for key, value in resource_limits_config.items(): + env_lines.append(f"{key.upper()}={value}") + + # Add network configurations + network_config = m4b_config.get('network', {}) + for key, value in network_config.items(): + env_lines.append(f"NETWORK_{key.upper()}={value}") + + # Add user and device configurations + device_info = user_config.get('device_info', {}) + for key, value in device_info.items(): + env_lines.append(f"{key.upper()}={value}") + + # Add m4b_dashboard configurations + m4b_dashboard_config = user_config.get('m4b_dashboard', {}) + for key, value in m4b_dashboard_config.items(): + env_lines.append(f"M4B_DASHBOARD_{key.upper()}={value}") + + # Add proxy configurations + proxy_config = user_config.get('proxies', {}) + for key, value in proxy_config.items(): + env_lines.append(f"STACK_PROXY_{key.upper()}={value}") + + # Add notification configurations if enabled + notifications_config = user_config.get('notifications', {}) + if notifications_config.get('enabled'): + for key, value in notifications_config.items(): + env_lines.append(f"WATCHTOWER_NOTIFICATION_{key.upper()}={value}") + + # Add app-specific configurations only if the app is enabled + apps_categories = ['apps'] + if is_main_instance: + apps_categories.append('extra-apps') + for category in apps_categories: + for app in app_config.get(category, []): + app_name = app['name'].upper() + app_flags = app.get('flags', {}) + app_user_config = user_config['apps'].get(app['name'].lower(), {}) + if app_user_config.get('enabled', False): + for flag_name in app_flags.keys(): + if flag_name in app_user_config: + env_var_name = f"{app_name}_{flag_name.upper()}" + env_var_value = app_user_config[flag_name] + env_lines.append(f"{env_var_name}={env_var_value}") + + # Add app dashboard port if it exists + if 'dashboard_port' in app_user_config: + env_lines.append(f"{app_name.upper()}_DASHBOARD_PORT={app_user_config['dashboard_port']}") + + # Write to .env file + with open(env_output_path, 'w') as f: + f.write('\n'.join(env_lines)) + logging.info(f".env file generated and saved to {env_output_path}") + finally: + event.set() + spinner_thread.join() + def generate_dashboard_urls(compose_project_name: str, device_name: str, env_file: str = str(os.path.join(os.getcwd(), ".env"))) -> None: """ @@ -253,36 +272,45 @@ def generate_dashboard_urls(compose_project_name: str, device_name: str, env_fil Raises: Exception: If an error occurs during the URL generation process. """ - if not compose_project_name or not device_name: - if os.path.isfile(env_file): - logging.info("Reading COMPOSE_PROJECT_NAME and DEVICE_NAME from .env file...") - with open(env_file, 'r') as f: - for line in f: - if 'COMPOSE_PROJECT_NAME' in line: - compose_project_name = line.split('=')[1].strip() - if 'DEVICE_NAME' in line: - device_name = line.split('=')[1].strip() - else: - logging.error("Error: Parameters not provided and .env file not found.") + event = threading.Event() + spinner_thread = threading.Thread(target=show_spinner, args=("Generating dashboard URLs...", event)) + spinner_thread.start() + + try: + if not compose_project_name or not device_name: + if os.path.isfile(env_file): + logging.info("Reading COMPOSE_PROJECT_NAME and DEVICE_NAME from .env file...") + with open(env_file, 'r') as f: + for line in f: + if 'COMPOSE_PROJECT_NAME' in line: + compose_project_name = line.split('=')[1].strip() + if 'DEVICE_NAME' in line: + device_name = line.split('=')[1].strip() + else: + logging.error("Error: Parameters not provided and .env file not found.") + return + + if not compose_project_name or not device_name: + logging.error("Error: COMPOSE_PROJECT_NAME and DEVICE_NAME must be provided.") return - if not compose_project_name or not device_name: - logging.error("Error: COMPOSE_PROJECT_NAME and DEVICE_NAME must be provided.") - return + dashboard_file = f"dashboards_URLs_{compose_project_name}-{device_name}.txt" + with open(dashboard_file, 'w') as f: + f.write(f"------ Dashboards {compose_project_name}-{device_name} ------\n") - dashboard_file = f"dashboards_URLs_{compose_project_name}-{device_name}.txt" - with open(dashboard_file, 'w') as f: - f.write(f"------ Dashboards {compose_project_name}-{device_name} ------\n") + result = subprocess.run(["docker", "ps", "--format", "{{.Ports}} {{.Names}}"], capture_output=True, text=True) + for line in result.stdout.splitlines(): + container_info = line.split()[-1] + port_mapping = re.search(r'0.0.0.0:(\d+)->', line) + if port_mapping: + with open(dashboard_file, 'a') as f: + f.write(f"If enabled you can visit the {container_info} web dashboard on http://localhost:{port_mapping.group(1)}\n") - result = subprocess.run(["docker", "ps", "--format", "{{.Ports}} {{.Names}}"], capture_output=True, text=True) - for line in result.stdout.splitlines(): - container_info = line.split()[-1] - port_mapping = re.search(r'0.0.0.0:(\d+)->', line) - if port_mapping: - with open(dashboard_file, 'a') as f: - f.write(f"If enabled you can visit the {container_info} web dashboard on http://localhost:{port_mapping.group(1)}\n") + logging.info(f"Dashboard URLs have been written to {dashboard_file}") + finally: + event.set() + spinner_thread.join() - logging.info(f"Dashboard URLs have been written to {dashboard_file}") def generate_device_name(adjectives: list, animals: list, device_name: str = "", use_uuid_suffix: bool = False) -> str: """ diff --git a/utils/helper.py b/utils/helper.py index 1f65252..a3e26e9 100644 --- a/utils/helper.py +++ b/utils/helper.py @@ -3,6 +3,10 @@ import platform import subprocess import logging +import threading +from itertools import cycle +import sys +import time from colorama import Fore, Style @@ -158,3 +162,24 @@ def ensure_service(service_name="docker.binfmt", service_file_path='./.resources except Exception as e: logging.error(f"Failed to ensure {service_name} service: {str(e)}") raise RuntimeError(f"Failed to ensure {service_name} service: {str(e)}") + + +def show_spinner(message: str, event: threading.Event): + """ + Display a spinner animation in the console to indicate progress. + + Args: + message (str): The message to display alongside the spinner. + event (threading.Event): A threading event to stop the spinner. + """ + spinner = cycle(['|', '/', '-', '\\']) + sys.stdout.write(f"{message} ") + sys.stdout.flush() + while not event.is_set(): + sys.stdout.write(next(spinner)) + sys.stdout.flush() + time.sleep(0.1) + sys.stdout.write('\b') # Remove the spinner character + sys.stdout.write('\b') # Clear the spinner when stopping + sys.stdout.write("Done\n") # Print a completion message + sys.stdout.flush()