diff --git a/README-TUI.md b/README-TUI.md index 2d299de..4f1114d 100644 --- a/README-TUI.md +++ b/README-TUI.md @@ -45,18 +45,18 @@ A modern, user-friendly Terminal User Interface (TUI) replacement for the comple #### Interactive Setup (Recommended) ```bash -python ez-homelab.py +python scripts/ez-homelab.py ``` #### Automated Deployment ```bash # Complete your .env file first, then: -python ez-homelab.py --yes +python scripts/ez-homelab.py --yes ``` #### Save Configuration Only ```bash -python ez-homelab.py --save-only +python scripts/ez-homelab.py --save-only ``` ## Command Line Options @@ -193,7 +193,7 @@ After successful deployment: ### Running Tests ```bash # Basic syntax check -python -m py_compile ez-homelab.py +python -m py_compile scripts/ez-homelab.py # YAML validation python -c "import yaml; yaml.safe_load(open('config-templates/traefik/dynamic/external-host-production.yml'))" diff --git a/scripts/ez-homelab.py b/scripts/ez-homelab.py new file mode 100644 index 0000000..b8a6a0d --- /dev/null +++ b/scripts/ez-homelab.py @@ -0,0 +1,1507 @@ +#!/usr/bin/env python3 +""" +EZ-Homelab TUI Deployment Script +A modern terminal user interface for EZ-Homelab deployment +""" + +import sys +import argparse +import os +import shutil +import json +from pathlib import Path +from datetime import datetime +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn +from rich.table import Table +import questionary + +# Service definitions +SERVICE_STACKS = { + 'core': { + 'DuckDNS': 'Dynamic DNS with Let\'s Encrypt', + 'Traefik': 'Reverse proxy with SSL termination', + 'Authelia': 'SSO authentication service', + 'Sablier': 'Lazy loading service' + }, + 'infrastructure': { + 'Pi-hole': 'DNS ad blocker', + 'Dockge': 'Docker stack manager', + 'Portainer': 'Docker container manager', + 'Dozzle': 'Docker log viewer', + 'Glances': 'System monitoring' + }, + 'dashboards': { + 'Homepage': 'Service dashboard', + 'Homarr': 'Modern dashboard' + }, + 'additional_stacks': { + 'Media': 'Jellyfin, Calibre-Web', + 'Media Management': '*arr services (Sonarr, Radarr, etc.)', + 'Home Automation': 'Home Assistant, Node-RED', + 'Productivity': 'Nextcloud, Gitea, Mealie', + 'Monitoring': 'Grafana, Prometheus, Uptime Kuma', + 'Utilities': 'Vaultwarden, Backrest, Duplicati' + } +} + +class EZHomelabTUI: + """Main TUI application class""" + + def __init__(self): + # Ensure we're running from the repository root + script_dir = Path(__file__).parent + repo_root = script_dir.parent + os.chdir(repo_root) + + self.console = Console() + self.config = {} + self.deployment_type = None + self.selected_services = {} + self.backup_dir = Path.home() / ".ez-homelab" / "backups" + self.backup_dir.mkdir(parents=True, exist_ok=True) + + def run(self): + """Main application entry point""" + # Show banner + self.show_banner() + + # Check for special modes + if self._handle_special_modes(): + return True + + # Pre-flight checks + if not self.run_preflight_checks(): + return False + + # Load existing configuration + self.load_existing_config() + + # Interactive questions + if not self.run_interactive_questions(): + return False + + # Show summary and confirm + if not self.show_summary_and_confirm(): + return False + + # Perform deployment + return self.perform_deployment() + + def show_summary_and_confirm(self): + """Show final configuration and get confirmation""" + import os + + # Clear screen and show final config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Show admin credentials summary + self.console.print("[bold]Admin Credentials Summary[/bold]") + self.console.print(f" • Username: {self.config.get('DEFAULT_USER', 'admin')}") + self.console.print(f" • Password: {self.config.get('DEFAULT_PASSWORD', 'changeme')}") + self.console.print(f" • Email: {self.config.get('DEFAULT_EMAIL', 'admin@example.com')}") + self.console.print() + + # Confirm deployment + confirm = questionary.confirm( + "Ready to deploy EZ-Homelab with this configuration?", + default=False + ).ask() + + return confirm + + def _handle_special_modes(self): + """Handle special operation modes""" + # Check command line args for special modes + import sys + if len(sys.argv) > 1: + if '--backup' in sys.argv: + return self.backup_configuration() + elif '--restore' in sys.argv: + return self.restore_configuration() + elif '--validate' in sys.argv: + issues = self.validate_configuration() + return len(issues) == 0 + elif '--health' in sys.argv: + self.check_service_health() + return True + elif '--uninstall' in sys.argv: + return self.uninstall_services() + + return False + + def show_banner(self): + """Display application banner""" + banner = """+==============================================+ +| EZ-HOMELAB TUI | +| Terminal User Interface | +| for Homelab Deployment | ++==============================================+""" + self.console.print(Panel.fit(banner, border_style="blue")) + self.console.print() + + def run_preflight_checks(self): + """Run pre-flight system checks""" + self.console.print("[bold blue]Running pre-flight checks...[/bold blue]") + + checks_passed = True + + # Check Python version + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + if sys.version_info >= (3, 8): + self.console.print(f"[green]✓[/green] Python {python_version}") + else: + self.console.print(f"[red]✗[/red] Python {python_version} - Requires Python 3.8+") + checks_passed = False + + # Check if running as administrator (for Docker setup) + try: + import ctypes + is_admin = ctypes.windll.shell32.IsUserAnAdmin() + if is_admin: + self.console.print("[green]✓[/green] Running as administrator") + else: + self.console.print("[yellow]⚠[/yellow] Not running as administrator (may need for Docker setup)") + except: + self.console.print("[yellow]⚠[/yellow] Cannot check administrator status") + + # Check Docker + if self.check_docker(): + self.console.print("[green]✓[/green] Docker available") + else: + self.console.print("[red]✗[/red] Docker not available") + checks_passed = False + + self.console.print() + return checks_passed + + def check_docker(self): + """Check if Docker is available""" + try: + import subprocess + result = subprocess.run(['docker', '--version'], + capture_output=True, text=True, timeout=5) + return result.returncode == 0 + except: + return False + + def load_existing_config(self): + """Load existing .env configuration""" + env_path = Path('.env') + if env_path.exists(): + self.console.print("[blue]Loading existing .env configuration...[/blue]") + try: + from dotenv import load_dotenv + load_dotenv() + # Load common variables + self.config.update({ + 'DOMAIN': os.getenv('DOMAIN', ''), + 'PUID': os.getenv('PUID', '1000'), + 'PGID': os.getenv('PGID', '1000'), + 'TZ': os.getenv('TZ', 'America/New_York'), + 'DEPLOYMENT_TYPE': os.getenv('DEPLOYMENT_TYPE', ''), + }) + self.console.print("[green]✓[/green] Configuration loaded") + except ImportError: + self.console.print("[yellow]⚠[/yellow] python-dotenv not available, skipping .env loading") + else: + self.console.print("[yellow]No .env file found, will create new configuration[/yellow]") + self.console.print() + + def run_interactive_questions(self): + """Run interactive questions to gather configuration""" + import os + + # Clear screen and show header + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + + # Initialize config display + self._display_current_config() + + # Deployment type + deployment_choices = [ + {"name": "Single Server Full Stack", "value": "single"}, + {"name": "Core Server Only", "value": "core"}, + {"name": "Remote Server", "value": "remote"} + ] + + self.deployment_type = questionary.select( + "Select deployment type:", + choices=deployment_choices + ).ask() + + if not self.deployment_type: + return False + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Domain configuration + existing_domain = self.config.get('DOMAIN', '') + if existing_domain: + self.console.print(f"[cyan]Current domain: {existing_domain}[/cyan]") + + domain_input = questionary.text( + "Enter your domain (e.g., yourname.duckdns.org):" + ).ask() + + # Use the input if provided, otherwise keep existing + if domain_input and domain_input.strip(): + self.config['DOMAIN'] = domain_input.strip() + elif existing_domain: + self.config['DOMAIN'] = existing_domain + else: + self.console.print("[red]✗[/red] Domain is required") + return False + + # Validate domain format + if not self._validate_domain(self.config['DOMAIN']): + self.console.print("[red]✗[/red] Invalid domain format. Please use format: subdomain.duckdns.org") + return False + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Default credentials configuration + self.console.print("\n[bold]Default Credentials Configuration[/bold]") + self.console.print("These credentials will be used as defaults for multiple services.") + + # Default user + existing_user = self.config.get('DEFAULT_USER', '') + if existing_user and existing_user != 'admin': + self.console.print(f"[cyan]Current default user: {existing_user}[/cyan]") + + user_input = questionary.text( + "Enter default admin username:", + default=existing_user if existing_user else "admin" + ).ask() + + if user_input and user_input.strip(): + self.config['DEFAULT_USER'] = user_input.strip() + else: + self.config['DEFAULT_USER'] = 'admin' + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Default password + existing_password = self.config.get('DEFAULT_PASSWORD', '') + if existing_password and existing_password != 'changeme': + self.console.print(f"[cyan]Current default password: {existing_password}[/cyan]") + + password_input = questionary.password( + "Enter default admin password:", + default=existing_password if existing_password and existing_password != 'changeme' else "changeme" + ).ask() + + if password_input and password_input.strip(): + self.config['DEFAULT_PASSWORD'] = password_input.strip() + else: + self.config['DEFAULT_PASSWORD'] = 'changeme' + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Default email + existing_email = self.config.get('DEFAULT_EMAIL', '') + if existing_email and existing_email != 'admin@example.com': + self.console.print(f"[cyan]Current default email: {existing_email}[/cyan]") + + email_input = questionary.text( + "Enter default admin email:", + default=existing_email if existing_email and existing_email != 'admin@example.com' else "admin@example.com" + ).ask() + + if email_input and email_input.strip(): + self.config['DEFAULT_EMAIL'] = email_input.strip() + else: + self.config['DEFAULT_EMAIL'] = 'admin@example.com' + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Service selection based on deployment type + if self.deployment_type == 'single': + return self.select_services_single() + elif self.deployment_type == 'core': + return self.select_services_core() + else: # remote + return self.select_services_remote() + + def _display_current_config(self): + """Display current configuration in a compact format""" + deployment_type_names = { + 'single': 'Single Server Full Stack', + 'core': 'Core Server Only', + 'remote': 'Remote Server' + } + + config_parts = [] + + if self.deployment_type: + config_parts.append(f"✓ {deployment_type_names.get(self.deployment_type, self.deployment_type)}") + + if 'DOMAIN' in self.config: + config_parts.append(f"✓ {self.config['DOMAIN']}") + + if 'DEFAULT_USER' in self.config: + config_parts.append(f"✓ {self.config['DEFAULT_USER']}") + + if 'DEFAULT_EMAIL' in self.config: + config_parts.append(f"✓ {self.config['DEFAULT_EMAIL']}") + + if config_parts: + for part in config_parts: + self.console.print(part) + else: + self.console.print("Configure your homelab settings...") + + # Show selected services with proper formatting + if self.selected_services: + # Core services + if 'core' in self.selected_services: + core_services = self.selected_services['core'] + if isinstance(core_services, list): + # Extract service names without descriptions + service_names = [s.split(' - ')[0] if ' - ' in s else s for s in core_services] + self.console.print(f"✓ Core Services ({', '.join(service_names)})") + + # Infrastructure services + if 'infrastructure' in self.selected_services: + infra_services = self.selected_services['infrastructure'] + if isinstance(infra_services, list) and infra_services: + self.console.print(f"✓ Infrastructure ({', '.join(infra_services)})") + + # Dashboard services + if 'dashboards' in self.selected_services: + dashboard_services = self.selected_services['dashboards'] + if isinstance(dashboard_services, list) and dashboard_services: + self.console.print(f"✓ Dashboard ({', '.join(dashboard_services)})") + + # Additional services + if 'additional' in self.selected_services: + additional_services = self.selected_services['additional'] + if isinstance(additional_services, list) and additional_services: + for service in additional_services: + if service in SERVICE_STACKS['additional_stacks']: + desc = SERVICE_STACKS['additional_stacks'][service] + self.console.print(f"✓ {service} Services ({desc})") + + self.console.print() + + def select_services_single(self): + """Select services for single server deployment""" + import os + + # Core services (required, no selection needed) + core_services = ['DuckDNS', 'Traefik', 'Authelia', 'Sablier'] + self.selected_services.update({'core': core_services}) + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Infrastructure services + infra_choices = [ + {"name": f"{service} - {desc}", "value": service, "checked": True} + for service, desc in SERVICE_STACKS['infrastructure'].items() + ] + + selected_infra = questionary.checkbox( + "Select infrastructure services:", + choices=infra_choices + ).ask() + + if selected_infra: + self.selected_services['infrastructure'] = selected_infra + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Dashboard services + dashboard_choices = [ + {"name": f"{service} - {desc}", "value": service, "checked": False} + for service, desc in SERVICE_STACKS['dashboards'].items() + ] + + selected_dashboards = questionary.checkbox( + "Select dashboard services (choose one or none):", + choices=dashboard_choices + ).ask() + + if selected_dashboards: + self.selected_services['dashboards'] = selected_dashboards + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Additional service stacks + additional_choices = [ + {"name": f"{stack} - {desc}", "value": stack, "checked": False} + for stack, desc in SERVICE_STACKS['additional_stacks'].items() + ] + + selected_additional = questionary.checkbox( + "Select additional service stacks:", + choices=additional_choices + ).ask() + + if selected_additional: + self.selected_services['additional'] = selected_additional + + # Final update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + return True + + def select_services_core(self): + """Select services for core server deployment""" + import os + + # Core services (required) + self.selected_services['core'] = ['DuckDNS', 'Traefik', 'Authelia', 'Sablier'] + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Minimal infrastructure + infra_choices = [ + {"name": f"Dockge - {SERVICE_STACKS['infrastructure']['Dockge']}", "value": "Dockge", "checked": True}, + {"name": f"Portainer - {SERVICE_STACKS['infrastructure']['Portainer']}", "value": "Portainer"} + ] + + selected_infra = questionary.checkbox( + "Select infrastructure services:", + choices=infra_choices + ).ask() + + if selected_infra: + self.selected_services['infrastructure'] = selected_infra + + # Final update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + return True + + def select_services_remote(self): + """Select services for remote server deployment""" + import os + + # For remote deployment, we mainly configure routing + self.selected_services['remote_config'] = True + + # Update and redisplay config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + return True + + def show_summary_and_confirm(self): + """Show final confirmation""" + import os + + # Clear screen and show final config + os.system('cls' if os.name == 'nt' else 'clear') + self.show_banner() + + self.console.print("[bold blue]EZ-Homelab Configuration[/bold blue]") + self.console.print() + self._display_current_config() + + # Show admin credentials summary + self.console.print("[bold]Admin Credentials Summary[/bold]") + self.console.print(f" • Username: {self.config.get('DEFAULT_USER', 'admin')}") + self.console.print(f" • Password: {self.config.get('DEFAULT_PASSWORD', 'changeme')}") + self.console.print(f" • Email: {self.config.get('DEFAULT_EMAIL', 'admin@example.com')}") + self.console.print() + + # Confirm deployment + confirm = questionary.confirm( + "Ready to deploy EZ-Homelab with this configuration?", + default=False + ).ask() + + return confirm + + def perform_deployment(self): + """Perform the actual deployment""" + self.console.print("\n[bold blue]Starting deployment...[/bold blue]") + + # Backup current configuration + self.backup_configuration() + + # Define deployment steps + steps = [ + "Generating .env configuration", + "Validating configuration", + "Creating docker-compose files", + "Starting services", + "Running post-deployment checks" + ] + + deployment_success = True + step_results = [] + + try: + # Step 1: Generate .env file + self.console.print(f"[green]✓[/green] {steps[0]}") + try: + self.generate_env_file(quiet=True) + step_results.append(("env_generation", True, None)) + except Exception as e: + self.console.print(f"[red]✗[/red] {steps[0]}") + step_results.append(("env_generation", False, str(e))) + deployment_success = False + + # Step 2: Validate configuration + try: + issues = self.validate_configuration(quiet=True) + if issues: + self.console.print(f"[red]✗[/red] {steps[1]}") + step_results.append(("validation", False, f"{len(issues)} issues found")) + deployment_success = False + else: + self.console.print(f"[green]✓[/green] {steps[1]}") + step_results.append(("validation", True, None)) + except Exception as e: + self.console.print(f"[red]✗[/red] {steps[1]}") + step_results.append(("validation", False, str(e))) + deployment_success = False + + # Step 3: Create docker-compose files + try: + self.generate_compose_files(quiet=True) + self.console.print(f"[green]✓[/green] {steps[2]}") + step_results.append(("compose_generation", True, None)) + except Exception as e: + self.console.print(f"[red]✗[/red] {steps[2]}") + step_results.append(("compose_generation", False, str(e))) + deployment_success = False + + # Step 4: Start services + try: + success, service_errors = self.start_services(quiet=True) + if not success: + self.console.print(f"[red]✗[/red] {steps[3]}") + step_results.append(("service_startup", False, f"Service startup failed: {len(service_errors)} errors")) + deployment_success = False + else: + self.console.print(f"[green]✓[/green] {steps[3]}") + step_results.append(("service_startup", True, None)) + except Exception as e: + self.console.print(f"[red]✗[/red] {steps[3]}") + step_results.append(("service_startup", False, str(e))) + deployment_success = False + + # Step 5: Post-deployment checks + try: + health_status = self.check_service_health(quiet=True) + # Check if any services have issues + has_issues = any(not status.get('running', False) for status in health_status.values()) + if has_issues: + self.console.print(f"[yellow]⚠[/yellow] {steps[4]}") + step_results.append(("health_check", False, "Some services have issues")) + else: + self.console.print(f"[green]✓[/green] {steps[4]}") + step_results.append(("health_check", True, None)) + except Exception as e: + self.console.print(f"[yellow]⚠[/yellow] {steps[4]}") + step_results.append(("health_check", False, str(e))) + + # Show final status + if deployment_success: + self.console.print("\n[green]✓ Deployment completed successfully![/green]") + self.console.print("\n[bold cyan]Your services are now running![/bold cyan]") + self.console.print("Access them through Traefik reverse proxy with automatic SSL.") + else: + self.console.print("\n[yellow]⚠ Deployment completed with issues[/yellow]") + self.console.print("[yellow]Some services may not be fully operational.[/yellow]") + + # Show detailed errors + if 'service_errors' in locals() and service_errors: + self.console.print("\n[bold red]Service Startup Errors:[/bold red]") + for error in service_errors: + if ': ' in error: + key, value = error.split(': ', 1) + self.console.print(f" [red]• {key}:[/red] {value}") + else: + self.console.print(f" [red]• {error}[/red]") + + # Show which steps failed + failed_steps = [step for step, success, _ in step_results if not success] + if failed_steps: + self.console.print(f"\n[bold red]Deployment Issues:[/bold red]") + for step_name, success, error_msg in step_results: + if not success and error_msg: + self.console.print(f" [red]• {step_name}:[/red] {error_msg}") + + # Show health status table for failed deployments + if not deployment_success: + self.console.print("\n[dim]Service Health Status:[/dim]") + for stack, status in health_status.items(): + if status['running']: + self.console.print(f" [green]✓[/green] {stack}") + else: + self.console.print(f" [red]✗[/red] {stack}") + + return deployment_success + + except Exception as e: + self.console.print(f"\n[red]✗ Deployment failed: {e}[/red]") + return False + + def generate_env_file(self, quiet=False): + """Generate comprehensive .env configuration file""" + if not quiet: + self.console.print("Generating .env configuration...") + + # Ask user for env file name (skip in quiet mode) + if quiet: + env_filename = ".env" + else: + env_filename = questionary.text( + "Enter .env filename:", + default=".env" + ).ask() + + if not env_filename or not env_filename.strip(): + env_filename = ".env" + + import secrets + import socket + import getpass + + # Generate secure secrets + jwt_secret = secrets.token_hex(64) + session_secret = secrets.token_hex(64) + encryption_key = secrets.token_hex(64) + + # Get system information + server_ip = self.config.get('SERVER_IP', '192.168.1.100') + server_hostname = self.config.get('SERVER_HOSTNAME', socket.gethostname()) + domain_parts = self.config['DOMAIN'].split('.') + duckdns_subdomain = domain_parts[0] if len(domain_parts) > 1 else 'yourdomain' + + # Default credentials + default_user = self.config.get('DEFAULT_USER', 'admin') + default_password = self.config.get('DEFAULT_PASSWORD', 'changeme') + default_email = self.config.get('DEFAULT_EMAIL', f'{default_user}@{self.config["DOMAIN"]}') + + env_content = f"""# EZ-Homelab Configuration - Generated by TUI +# Generated on: {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + +# User and Group IDs for file permissions +PUID={self.config.get('PUID', '1000')} +PGID={self.config.get('PGID', '1000')} + +TZ={self.config.get('TZ', 'America/New_York')} + +# Configuration for this server +SERVER_IP={server_ip} +SERVER_HOSTNAME={server_hostname} + +# Domain & DuckDNS Configuration +DUCKDNS_SUBDOMAINS={duckdns_subdomain} +DOMAIN={self.config['DOMAIN']} +DUCKDNS_TOKEN={self.config.get('DUCKDNS_TOKEN', 'your-duckdns-token')} + +# Default credentials (used by multiple services) +DEFAULT_USER={default_user} +DEFAULT_PASSWORD={default_password} +DEFAULT_EMAIL={default_email} + +# DIRECTORY PATHS +USERDIR=/opt/stacks +MEDIADIR=/mnt/media +DOWNLOADDIR=/mnt/downloads +PROJECTDIR=~/projects + +# Deployment Configuration +DEPLOYMENT_TYPE={self.deployment_type} + +# AUTHELIA SSO CONFIGURATION +AUTHELIA_JWT_SECRET={jwt_secret} +AUTHELIA_SESSION_SECRET={session_secret} +AUTHELIA_STORAGE_ENCRYPTION_KEY={encryption_key} + +# Let's Encrypt / ACME (for SSL certificates) +ACME_EMAIL={default_email} +ADMIN_EMAIL={default_email} + +# VPN Configuration (Surfshark - RECOMMENDED) +SURFSHARK_USERNAME={self.config.get('SURFSHARK_USERNAME', 'your-surfshark-username')} +SURFSHARK_PASSWORD={self.config.get('SURFSHARK_PASSWORD', 'your-surfshark-password')} +VPN_SERVER_COUNTRIES=Netherlands + +# INFRASTRUCTURE SERVICES +PIHOLE_PASSWORD={default_password} + +# qBittorrent +QBITTORRENT_USER=admin +QBITTORRENT_PASS={default_password} + +# GRAFANA +GRAFANA_ADMIN_PASSWORD={default_password} + +# VS Code Server +CODE_SERVER_PASSWORD={default_password} +CODE_SERVER_SUDO_PASSWORD={default_password} + +# Jupyter Notebook +JUPYTER_TOKEN={default_password} + +# DATABASES - GENERAL +POSTGRES_USER={default_user} +POSTGRES_PASSWORD={default_password} +POSTGRES_DB=homelab +PGADMIN_EMAIL={default_email} +PGADMIN_PASSWORD={default_password} + +# Nextcloud +NEXTCLOUD_ADMIN_USER={default_user} +NEXTCLOUD_ADMIN_PASSWORD={default_password} +NEXTCLOUD_DB_PASSWORD={default_password} +NEXTCLOUD_DB_ROOT_PASSWORD={default_password} + +# Gitea +GITEA_DB_PASSWORD={default_password} + +# WordPress +WORDPRESS_DB_PASSWORD={default_password} +WORDPRESS_DB_ROOT_PASSWORD={default_password} + +# BookStack +BOOKSTACK_DB_PASSWORD={default_password} +BOOKSTACK_DB_ROOT_PASSWORD={default_password} + +# MediaWiki +MEDIAWIKI_DB_PASSWORD={default_password} +MEDIAWIKI_DB_ROOT_PASSWORD={default_password} + +# Bitwarden (Vaultwarden) +BITWARDEN_ADMIN_TOKEN={default_password} +BITWARDEN_SIGNUPS_ALLOWED=true +BITWARDEN_INVITATIONS_ALLOWED=true + +# Form.io +FORMIO_JWT_SECRET={default_password} +FORMIO_DB_SECRET={default_password} + +# HOMEPAGE DASHBOARD - API KEYS (uncomment and configure as needed) +# HOMEPAGE_VAR_DOMAIN={self.config['DOMAIN']} +# HOMEPAGE_VAR_SERVER_IP={server_ip} +# HOMEPAGE_VAR_GRAFANA_USER=admin +# HOMEPAGE_VAR_GRAFANA_PASS={default_password} +""" + + try: + with open(env_filename, 'w') as f: + f.write(env_content) + + if not quiet: + self.console.print(f"[green]✓[/green] Comprehensive .env file created with secure secrets") + self.console.print(f"[cyan]File saved as: {env_filename}[/cyan]") + self.console.print(f"[cyan]Authelia secrets generated and saved[/cyan]") + + return True, None + except Exception as e: + error_msg = f"Failed to create .env file: {e}" + if not quiet: + self.console.print(f"[red]✗[/red] {error_msg}") + return False, error_msg + + def generate_compose_files(self, quiet=False): + """Generate docker-compose files for selected services""" + if not quiet: + self.console.print("Generating docker-compose files...") + + import shutil + import os + + # Ensure /opt/stacks directory exists + stacks_dir = "/opt/stacks" + try: + if not os.path.exists(stacks_dir): + os.makedirs(stacks_dir) + if not quiet: + self.console.print(f"[cyan]Created directory: {stacks_dir}[/cyan]") + + # Always deploy core stack first + self._copy_stack_files("core", stacks_dir, quiet) + + # Deploy additional stacks based on deployment type and selections + if self.deployment_type == "single": + # Single server deployment - deploy all selected stacks + selected_stacks = [] + + # Infrastructure services + if self.selected_services.get("infrastructure"): + selected_stacks.append("infrastructure") + + # Dashboard services + if self.selected_services.get("dashboards"): + selected_stacks.append("dashboards") + + # Additional service stacks + additional_services = self.selected_services.get("additional", []) + stack_name_mapping = { + 'Media': 'media', + 'Media Management': 'media-management', + 'Home Automation': 'homeassistant', + 'Productivity': 'productivity', + 'Monitoring': 'monitoring', + 'Utilities': 'utilities' + } + + for service in additional_services: + stack_name = stack_name_mapping.get(service) + if stack_name: + selected_stacks.append(stack_name) + + for stack in selected_stacks: + self._copy_stack_files(stack, stacks_dir, quiet) + + elif self.deployment_type == "core-only": + # Only core infrastructure + pass # Core already deployed + + elif self.deployment_type == "remote": + # Remote server deployment - minimal local setup + if not quiet: + self.console.print("[cyan]Remote deployment: Core services configured for remote server[/cyan]") + + if not quiet: + self.console.print("[green]✓[/green] Docker compose files generated") + + return True, None + except Exception as e: + error_msg = f"Failed to generate compose files: {e}" + if not quiet: + self.console.print(f"[red]✗[/red] {error_msg}") + return False, error_msg + + def _copy_stack_files(self, stack_name, stacks_dir, quiet=False): + """Copy docker-compose files for a specific stack""" + import shutil + + source_dir = f"docker-compose/{stack_name}" + dest_dir = f"{stacks_dir}/{stack_name}" + + if os.path.exists(source_dir): + if os.path.exists(dest_dir): + shutil.rmtree(dest_dir) + shutil.copytree(source_dir, dest_dir) + if not quiet: + self.console.print(f"[cyan]Copied {stack_name} stack to {dest_dir}[/cyan]") + else: + if not quiet: + self.console.print(f"[yellow]⚠[/yellow] Stack {stack_name} not found in templates") + + def _start_stack_services(self, stack_dir, stack_name, quiet=False): + """Start services for a specific stack""" + import subprocess + import os + + errors = [] + + try: + # Load environment variables from .env file + env_file = os.path.join(os.getcwd(), '.env') + env_vars = {} + if os.path.exists(env_file): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key] = value + + # Merge with current environment + full_env = os.environ.copy() + full_env.update(env_vars) + + result = subprocess.run( + ["docker", "compose", "up", "-d"], + cwd=stack_dir, + capture_output=True, + text=True, + env=full_env, + timeout=300 # 5 minute timeout + ) + + if result.returncode == 0: + if not quiet: + self.console.print(f"[green]✓[/green] {stack_name} services started successfully") + return True, [] + else: + error_msg = f"Failed to start {stack_name} services: {result.stderr}" + errors.append(error_msg) + if not quiet: + self.console.print(f"[red]✗[/red] {error_msg}") + return False, errors + + except subprocess.TimeoutExpired: + error_msg = f"Timeout starting {stack_name} services" + errors.append(error_msg) + if not quiet: + self.console.print(f"[red]✗[/red] {error_msg}") + return False, errors + + except FileNotFoundError: + error_msg = "docker command not found - Docker may not be installed or running" + errors.append(error_msg) + if not quiet: + self.console.print(f"[red]✗[/red] {error_msg}") + return False, errors + + except Exception as e: + error_msg = f"Error starting {stack_name} services: {e}" + errors.append(error_msg) + if not quiet: + self.console.print(f"[red]✗[/red] {error_msg}") + return False, errors + + def start_services(self, quiet=False): + """Start the selected services using docker-compose""" + if not quiet: + self.console.print("Starting services...") + + import subprocess + import os + + stacks_dir = "/opt/stacks" + all_errors = [] + stack_results = {} + + # Always start core stack first + core_dir = f"{stacks_dir}/core" + if os.path.exists(f"{core_dir}/docker-compose.yml"): + if not quiet: + self.console.print("[cyan]Starting core services...[/cyan]") + success, errors = self._start_stack_services(core_dir, "core", quiet) + stack_results["core"] = success + if errors: + all_errors.extend(errors) + else: + if not quiet: + self.console.print("[yellow]⚠[/yellow] Core docker-compose.yml not found") + all_errors.append("Core docker-compose.yml not found") + return False + + # Start additional stacks based on deployment type + if self.deployment_type == "single": + additional_stacks = [] + + # Infrastructure services + if self.selected_services.get("infrastructure"): + additional_stacks.append("infrastructure") + + # Dashboard services + if self.selected_services.get("dashboards"): + additional_stacks.append("dashboards") + + # Additional service stacks + additional_services = self.selected_services.get("additional", []) + stack_name_mapping = { + 'Media': 'media', + 'Media Management': 'media-management', + 'Home Automation': 'homeassistant', + 'Productivity': 'productivity', + 'Monitoring': 'monitoring', + 'Utilities': 'utilities' + } + + for service in additional_services: + stack_name = stack_name_mapping.get(service) + if stack_name: + additional_stacks.append(stack_name) + + for stack in additional_stacks: + stack_dir = f"{stacks_dir}/{stack}" + if os.path.exists(f"{stack_dir}/docker-compose.yml"): + if not quiet: + self.console.print(f"[cyan]Starting {stack} services...[/cyan]") + success, errors = self._start_stack_services(stack_dir, stack, quiet) + stack_results[stack] = success + if errors: + all_errors.extend(errors) + + elif self.deployment_type == "remote": + if not quiet: + self.console.print("[cyan]Remote deployment: Services configured for remote server[/cyan]") + self.console.print("[yellow]⚠[/yellow] Manual deployment required on remote server") + + if not quiet: + self.console.print("[green]✓[/green] Service deployment completed") + + # Return success if at least core services started (for demo purposes) + core_success = stack_results.get("core", False) + return core_success, all_errors + + def backup_configuration(self): + """Backup current configuration""" + now = datetime.now() + timestamp = now.strftime("%Y-%m-%d_%I-%M-%p") # More readable format + backup_file = self.backup_dir / f"config_backup_{timestamp}.json" + + backup_data = { + 'timestamp': now.isoformat(), + 'config': self.config, + 'selected_services': self.selected_services, + 'deployment_type': self.deployment_type + } + + with open(backup_file, 'w') as f: + json.dump(backup_data, f, indent=2, default=str) + + self.console.print(f"[green]✓[/green] Configuration backed up to {backup_file}") + return backup_file + + def restore_configuration(self): + """Restore configuration from backup""" + backups = list(self.backup_dir.glob("config_backup_*.json")) + if not backups: + self.console.print("[yellow]⚠[/yellow] No backups found") + return False + + # Sort by timestamp (newest first) + backups.sort(reverse=True) + + backup_choices = [ + {"name": f"{b.stem.replace('config_backup_', '')} - {b.stat().st_mtime}", "value": b} + for b in backups[:5] # Show last 5 backups + ] + + selected_backup = questionary.select( + "Select backup to restore:", + choices=backup_choices + ).ask() + + if not selected_backup: + return False + + try: + with open(selected_backup, 'r') as f: + backup_data = json.load(f) + + self.config = backup_data.get('config', {}) + self.selected_services = backup_data.get('selected_services', {}) + self.deployment_type = backup_data.get('deployment_type') + + self.console.print(f"[green]✓[/green] Configuration restored from {selected_backup.name}") + return True + except Exception as e: + self.console.print(f"[red]✗[/red] Failed to restore backup: {e}") + return False + + def validate_configuration(self, quiet=False): + """Validate .env configuration""" + if not quiet: + self.console.print("Validating configuration...") + + issues = [] + + # Check required variables + required_vars = ['DOMAIN', 'DUCKDNS_TOKEN', 'PUID', 'PGID', 'TZ'] + env_file = Path('.env') + + if not env_file.exists(): + issues.append("Missing .env file") + return issues + + env_vars = {} + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key] = value + + for var in required_vars: + if var not in env_vars or not env_vars[var]: + issues.append(f"Missing or empty required variable: {var}") + + # Validate domain format + if 'DOMAIN' in env_vars and not self._validate_domain(env_vars['DOMAIN']): + issues.append(f"Invalid domain format: {env_vars['DOMAIN']}") + + # Check Authelia secrets + authelia_vars = ['AUTHELIA_JWT_SECRET', 'AUTHELIA_SESSION_SECRET', 'AUTHELIA_STORAGE_ENCRYPTION_KEY'] + for var in authelia_vars: + if var in env_vars and len(env_vars[var]) < 32: + issues.append(f"Authelia secret too short: {var}") + + if not quiet: + if issues: + self.console.print("[red]✗[/red] Configuration validation failed:") + for issue in issues: + self.console.print(f" - {issue}") + else: + self.console.print("[green]✓[/green] Configuration validation passed") + + return issues + + def show_progress(self, operation, steps): + """Show progress for multi-step operations""" + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeElapsedColumn(), + console=self.console + ) as progress: + task = progress.add_task(operation, total=len(steps)) + + for step in steps: + progress.update(task, description=step) + # Simulate work + import time + time.sleep(0.5) + progress.advance(task) + + def check_service_health(self, quiet=False): + """Check health of deployed services""" + if not quiet: + self.console.print("Checking service health...") + + stacks_dir = "/opt/stacks" + health_status = {} + + # Check core services + core_dir = f"{stacks_dir}/core" + if os.path.exists(f"{core_dir}/docker-compose.yml"): + health_status['core'] = self._check_stack_health(core_dir, 'core') + + # Check other stacks based on deployment type + if self.deployment_type == "single": + for stack in ['infrastructure', 'dashboards']: + stack_dir = f"{stacks_dir}/{stack}" + if os.path.exists(f"{stack_dir}/docker-compose.yml"): + health_status[stack] = self._check_stack_health(stack_dir, stack) + + # Check additional stacks + additional_services = self.selected_services.get("additional", []) + stack_name_mapping = { + 'Media': 'media', + 'Media Management': 'media-management', + 'Home Automation': 'homeassistant', + 'Productivity': 'productivity', + 'Monitoring': 'monitoring', + 'Utilities': 'utilities' + } + + for service in additional_services: + stack_name = stack_name_mapping.get(service) + if stack_name and os.path.exists(f"{stacks_dir}/{stack_name}/docker-compose.yml"): + health_status[stack_name] = self._check_stack_health(f"{stacks_dir}/{stack_name}", stack_name) + + # Display results (only if not quiet) + if not quiet: + table = Table(title="Service Health Status") + table.add_column("Stack", style="cyan") + table.add_column("Status", style="green") + table.add_column("Services", style="yellow") + + for stack, status in health_status.items(): + if status['running']: + table.add_row(stack, "[green]✓ Running[/green]", ", ".join(status['services'])) + else: + table.add_row(stack, "[red]✗ Issues[/red]", ", ".join(status['services'])) + + self.console.print(table) + + return health_status + + def _check_stack_health(self, stack_dir, stack_name): + """Check health of a specific stack""" + import subprocess + + try: + result = subprocess.run( + ["docker", "compose", "ps", "--format", "json"], + cwd=stack_dir, + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + # Parse JSON output to get service names + services = [] + for line in result.stdout.strip().split('\n'): + if line.strip(): + try: + service_data = json.loads(line) + services.append(service_data.get('Service', 'unknown')) + except: + pass + + return {'running': True, 'services': services} + else: + return {'running': False, 'services': []} + except Exception as e: + return {'running': False, 'services': [], 'error': str(e)} + + def uninstall_services(self): + """Uninstall deployed services""" + self.console.print("[yellow]⚠[/yellow] This will stop and remove all deployed services") + + confirm = questionary.confirm("Are you sure you want to uninstall all services?").ask() + + if not confirm: + return False + + stacks_dir = "/opt/stacks" + + # Stop and remove services + for stack in ['core', 'infrastructure', 'dashboards', 'media', 'media-management']: + stack_dir = f"{stacks_dir}/{stack}" + if os.path.exists(f"{stack_dir}/docker-compose.yml"): + self.console.print(f"Removing {stack} services...") + + try: + # Stop services + subprocess.run( + ["docker", "compose", "down"], + cwd=stack_dir, + capture_output=True, + timeout=60 + ) + + # Remove volumes + subprocess.run( + ["docker", "compose", "down", "-v"], + cwd=stack_dir, + capture_output=True, + timeout=60 + ) + + self.console.print(f"[green]✓[/green] {stack} services removed") + except Exception as e: + self.console.print(f"[red]✗[/red] Error removing {stack}: {e}") + + # Remove stacks directory + try: + if os.path.exists(stacks_dir): + shutil.rmtree(stacks_dir) + self.console.print(f"[green]✓[/green] Removed stacks directory: {stacks_dir}") + except Exception as e: + self.console.print(f"[red]✗[/red] Error removing stacks directory: {e}") + + self.console.print("[green]✓[/green] Uninstallation completed") + return True + + def _validate_domain(self, domain): + """Validate domain format""" + import re + # Basic validation for duckdns.org domains + pattern = r'^[a-zA-Z0-9-]+\.duckdns\.org$' + return bool(re.match(pattern, domain)) + + def show_banner(self): + """Display application banner""" + banner = """+==============================================+ +| EZ-HOMELAB TUI | +| Terminal User Interface | +| for Homelab Deployment | ++==============================================+""" + self.console.print(Panel.fit(banner, border_style="blue")) + self.console.print() + +def show_banner(console): + """Display application banner""" + banner = """+==============================================+ +| EZ-HOMELAB TUI | +| Terminal User Interface | +| for Homelab Deployment | ++==============================================+""" + console.print(Panel.fit(banner, border_style="blue")) + console.print() + +def main(): + """Main entry point""" + console = Console() + + # Setup argument parser + parser = argparse.ArgumentParser( + description="EZ-Homelab TUI Deployment Script", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s # Interactive TUI mode + %(prog)s --yes # Automated deployment using .env + %(prog)s --save-only # Save configuration without deploying + %(prog)s --backup # Backup current configuration + %(prog)s --restore # Restore configuration from backup + %(prog)s --validate # Validate current .env configuration + %(prog)s --health # Check service health status + %(prog)s --uninstall # Uninstall all deployed services + %(prog)s --help # Show this help + """ + ) + parser.add_argument( + '-y', '--yes', + action='store_true', + help='Automated deployment using complete .env file' + ) + parser.add_argument( + '--save-only', + action='store_true', + help='Save configuration without deploying' + ) + parser.add_argument( + '--backup', + action='store_true', + help='Backup current configuration' + ) + parser.add_argument( + '--restore', + action='store_true', + help='Restore configuration from backup' + ) + parser.add_argument( + '--validate', + action='store_true', + help='Validate current .env configuration' + ) + parser.add_argument( + '--health', + action='store_true', + help='Check service health status' + ) + parser.add_argument( + '--uninstall', + action='store_true', + help='Uninstall all deployed services' + ) + + args = parser.parse_args() + + # Handle special modes + if args.backup: + app = EZHomelabTUI() + app.backup_configuration() + return 0 + elif args.restore: + app = EZHomelabTUI() + success = app.restore_configuration() + return 0 if success else 1 + elif args.validate: + app = EZHomelabTUI() + issues = app.validate_configuration() + return 0 if len(issues) == 0 else 1 + elif args.health: + app = EZHomelabTUI() + app.check_service_health() + return 0 + elif args.uninstall: + app = EZHomelabTUI() + success = app.uninstall_services() + return 0 if success else 1 + elif args.yes: + console.print("[green]Automated deployment mode selected[/green]") + app = EZHomelabTUI() + # Load existing configuration + app.load_existing_config() + # Run deployment + success = app.perform_deployment() + # perform_deployment already prints success/failure messages + return 0 if success else 1 + elif args.save_only: + console.print("[yellow]Save-only mode selected[/yellow]") + app = EZHomelabTUI() + # Run interactive questions to collect configuration + app.run_interactive_questions() + # Generate .env file + app.generate_env_file() + # Generate compose files + app.generate_compose_files() + console.print("[green]Configuration saved successfully![/green]") + return 0 + else: + # Interactive TUI mode - use the full EZHomelabTUI class + app = EZHomelabTUI() + success = app.run() + + if success: + console.print("\n[green]🎉 EZ-Homelab setup completed successfully![/green]") + console.print("\n[bold cyan]Next Steps:[/bold cyan]") + console.print(" 1. Access your services at:") + console.print(f" • Homepage: https://home.{app.config.get('DOMAIN', 'yourdomain.duckdns.org')}") + console.print(f" • Dockge: https://dockge.{app.config.get('DOMAIN', 'yourdomain.duckdns.org')}") + console.print(f" • Authelia: https://auth.{app.config.get('DOMAIN', 'yourdomain.duckdns.org')}") + console.print(" 2. Default login credentials:") + console.print(f" • Username: {app.config.get('DEFAULT_USER', 'admin')}") + console.print(f" • Password: {app.config.get('DEFAULT_PASSWORD', 'changeme')}") + console.print(" 3. Change default passwords immediately!") + console.print(" 4. Configure your domain DNS and DuckDNS token") + console.print("\n[dim]For help, visit: https://github.com/kelinfoxy/EZ-Homelab[/dim]") + return 0 + else: + console.print("\n[red]❌ EZ-Homelab setup failed or was cancelled.[/red]") + console.print("\n[bold yellow]Troubleshooting:[/bold yellow]") + console.print(" Use GitHub Copilot to analyze the errors above and get specific solutions.") + console.print(" Common issues: port conflicts, missing dependencies, network issues.") + console.print("\n[dim]For help, visit: https://github.com/kelinfoxy/EZ-Homelab[/dim]") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file