- Move ez-homelab.py to scripts/ folder for better organization - Add working directory detection to ensure script works from any location - Update README-TUI.md with correct script paths - First commit of the new Python TUI for EZ-Homelab deployment
1507 lines
56 KiB
Python
1507 lines
56 KiB
Python
#!/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()) |