#!/bin/bash install_hops() { # Clear terminal at startup clear # Exit on any error set -e # Script version for update tracking local SCRIPT_VERSION="1.0.0" local SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load system utilities source "$SCRIPT_DIR/lib/common.sh" source "$SCRIPT_DIR/lib/system.sh" # -------------------------------------------- # LOGGING SETUP # -------------------------------------------- setup_logging "homelab-setup" local_error_exit() { error_exit "$1" } # Enhanced error handling with rollback DEPLOYMENT_STEPS_COMPLETED=() track_step() { DEPLOYMENT_STEPS_COMPLETED+=("$1") log "āœ… Step completed: $1" } rollback_deployment() { log "šŸ”„ Rolling back deployment..." for step in "${DEPLOYMENT_STEPS_COMPLETED[@]}"; do case "$step" in "containers_started") log "šŸ›‘ Stopping containers..." docker compose down --timeout 30 2>/dev/null || true ;; "images_pulled") log "šŸ—‘ļø Removing pulled images..." docker compose down --rmi all 2>/dev/null || true ;; "directories_created") log "šŸ“ Cleaning up directories..." [[ -n "$APPDATA_DIR" ]] && rm -rf "$APPDATA_DIR" 2>/dev/null || true ;; "compose_generated") log "šŸ“ Removing compose file..." [[ -f "docker-compose.yml" ]] && rm -f docker-compose.yml ;; esac done log "šŸ”„ Rollback completed" } error_exit_with_rollback() { log "āŒ ERROR: $1" rollback_deployment log "āŒ Installation failed and rolled back. Check logs at: $LOG_FILE" exit 1 } # -------------------------------------------- # HEADER # -------------------------------------------- cat << "EOF" _ _ ____ ____ ____ | | | || _ \| _ \/ ___| | |__| || |_) | |_) \___ \ | __ || __/| __/ ___) | |_| |_||_| |_| |____/ EOF echo -e "šŸš€ Homelab Orchestration Provisioning Script v${SCRIPT_VERSION}\n" log "šŸš€ Starting HOPS Deployment v${SCRIPT_VERSION}" # -------------------------------------------- # SYSTEM REQUIREMENTS CHECK # -------------------------------------------- validate_system_requirements() { local MIN_RAM_GB=2 local MIN_DISK_GB=10 local MIN_CORES=2 info "šŸ” Validating system requirements..." # Detect OS first detect_os # Check system requirements using new abstraction check_system_requirements $MIN_RAM_GB $MIN_DISK_GB return 0 } # -------------------------------------------- # REQUIRED PACKAGES CHECK # -------------------------------------------- check_required_packages() { local missing_packages=() local required_packages=("curl" "wget" "openssl" "lsof") # Add OS-specific packages if [[ "$OS_NAME_LOWER" == "macos" ]]; then required_packages+=("httpd") # Apache on macOS (for htpasswd) else required_packages+=("apache2-utils") # Apache utils on Linux fi info "šŸ“¦ Checking required packages..." for package in "${required_packages[@]}"; do local check_cmd="${package%%-*}" if [[ "$package" == "httpd" ]]; then check_cmd="htpasswd" # Check for htpasswd command on macOS fi if ! command -v "$check_cmd" &>/dev/null; then missing_packages+=("$package") fi done if [[ ${#missing_packages[@]} -gt 0 ]]; then info "šŸ“¦ Installing missing packages: ${missing_packages[*]}" for package in "${missing_packages[@]}"; do install_package "$package" done fi success "āœ… All required packages are installed" } # -------------------------------------------- # ROOT CHECK # -------------------------------------------- if [[ $EUID -ne 0 ]]; then error_exit "This script must be run as root or with sudo." fi # OS detection is handled by the lib/system.sh functions # -------------------------------------------- # USER CONFIGURATION COLLECTION # -------------------------------------------- collect_user_configuration() { log "šŸ”§ Collecting user configuration..." # Get running user info if [[ -n "$SUDO_USER" ]]; then RUNNING_USER="$SUDO_USER" PUID=$(id -u "$SUDO_USER") PGID=$(id -g "$SUDO_USER") else RUNNING_USER="root" PUID=1000 PGID=1000 log "āš ļø Running as root, defaulting to PUID=1000, PGID=1000" fi # Timezone configuration echo -e "\nšŸŒ Timezone Configuration" echo "Current timezone: $(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")" echo -e "Keep current timezone? [Y/n]: " read -r keep_tz if [[ "$keep_tz" =~ ^[Nn]$ ]]; then echo -e "Enter timezone (e.g., America/New_York, Europe/London): " read -r user_timezone validate_timezone "$user_timezone" TIMEZONE="$user_timezone" else TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "America/New_York") fi # Directory configuration echo -e "\nšŸ“ Directory Configuration" local default_media_path=$(get_default_media_path) local default_config_path=$(get_default_config_path) echo -e "Media directory [$default_media_path]: " read -r media_dir MEDIA_DIR="${media_dir:-$default_media_path}" echo -e "Application data directory [$default_config_path]: " read -r appdata_dir APPDATA_DIR="${appdata_dir:-$default_config_path}" # Create directories mkdir -p "$MEDIA_DIR"/{movies,tv,music,books,downloads} mkdir -p "$APPDATA_DIR" # Set ownership to actual user (not root) if [[ -n "$SUDO_USER" ]]; then log "šŸ“ Setting ownership of directories to $SUDO_USER ($PUID:$PGID)" chown -R "$PUID:$PGID" "$MEDIA_DIR" "$APPDATA_DIR" 2>/dev/null || true fi log "āœ… User configuration collected" log " User: $RUNNING_USER ($PUID:$PGID)" log " Timezone: $TIMEZONE" log " Media: $MEDIA_DIR" log " AppData: $APPDATA_DIR" } # -------------------------------------------- # VALIDATION FUNCTIONS # -------------------------------------------- validate_timezone() { if ! timedatectl list-timezones | grep -qx "$1" 2>/dev/null; then log "āš ļø Timezone '$1' invalid, defaulting to 'America/New_York'" TIMEZONE="America/New_York" fi } validate_password() { local password="$1" local min_length="${2:-12}" if [[ -z "$password" ]]; then echo -e "\nšŸ” Password must meet these requirements:" echo " • Minimum $min_length characters" echo " • At least one uppercase letter" echo " • At least one lowercase letter" echo " • At least one number" echo " • At least one special character" return 3 fi if [[ ${#password} -lt $min_length ]]; then return 1 fi if [[ ! "$password" =~ [A-Z] ]] || [[ ! "$password" =~ [a-z] ]] || \ [[ ! "$password" =~ [0-9] ]] || [[ ! "$password" =~ [^A-Za-z0-9] ]]; then return 2 fi return 0 } check_port() { local PORT=$1 local SERVICE=$2 if lsof -i :"$PORT" >/dev/null 2>&1; then local PROCESS=$(lsof -ti :"$PORT" | head -1) local PROCESS_NAME=$(ps -p "$PROCESS" -o comm= 2>/dev/null || echo "unknown") log "āš ļø Port $PORT is already in use by $PROCESS_NAME. $SERVICE may fail to start." return 1 fi return 0 } check_all_ports() { local SERVICES=("$@") local CONFLICTS=() # Source service definitions to get port mappings if [[ -f "$SCRIPT_DIR/services" ]]; then source "$SCRIPT_DIR/services" fi for svc in "${SERVICES[@]}"; do local ports=$(get_service_ports "$svc") for port in $ports; do if ! check_port "$port" "$svc"; then CONFLICTS+=("Port $port ($svc)") fi done done if [[ ${#CONFLICTS[@]} -gt 0 ]]; then log "āš ļø Found ${#CONFLICTS[@]} port conflicts:" for conflict in "${CONFLICTS[@]}"; do log " • $conflict" done echo -e "\nāš ļø Port conflicts detected! Continue anyway? (y/N): " read -r continue_choice if [[ ! "$continue_choice" =~ ^[Yy]$ ]]; then error_exit "Installation cancelled due to port conflicts." fi return 1 fi return 0 } # -------------------------------------------- # DOCKER COMPOSE VERSION CHECK # -------------------------------------------- check_docker_compose_version() { # Check for Docker Compose plugin (v2) if docker compose version &>/dev/null; then log "āœ… Docker Compose plugin detected ($(docker compose version --short))" return 0 fi # Check for standalone docker-compose (v1) if command -v docker-compose &>/dev/null; then log "āš ļø Found legacy docker-compose (v1). Installing Docker Compose plugin..." if ! apt-get install -y docker-compose-plugin 2>&1 | tee -a "$LOG_FILE"; then error_exit "Failed to install Docker Compose plugin." fi return 0 fi # Neither found error_exit "No Docker Compose detected. Please install Docker first." } # -------------------------------------------- # IMPROVED PASSWORD GENERATION # -------------------------------------------- generate_secure_password() { local length="${1:-16}" local max_attempts=5 local attempt=1 while [[ $attempt -le $max_attempts ]]; do # Generate password with mixed case, numbers, and symbols local password=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-${length}) # Ensure it meets complexity requirements if validate_password "$password" "$length"; then echo "$password" return 0 fi attempt=$((attempt + 1)) done # Fallback: construct a guaranteed compliant password local upper=$(generate_chars 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 2) local lower=$(generate_chars 'abcdefghijklmnopqrstuvwxyz' 4) local digits=$(generate_chars '0123456789' 2) local symbols=$(generate_chars '!@#$%^&*' 2) local remaining_length=$((length - 10)) if [[ $remaining_length -gt 0 ]]; then local remaining=$(generate_chars 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' $remaining_length) shuffle_string "${upper}${lower}${digits}${symbols}${remaining}" else shuffle_string "${upper}${lower}${digits}${symbols}" fi } # -------------------------------------------- # ENVIRONMENT FILE GENERATION # -------------------------------------------- create_env_file() { local homelab_dir="$1" log "šŸ“ Creating environment file..." cat > "$homelab_dir/.env" </dev/null || true fi cd "$HOMELAB_DIR" if [[ -f docker-compose.yml ]]; then local BACKUP_FILE="docker-compose.yml.bak.$(date +%Y%m%d%H%M%S)" log "šŸ“ Backing up existing compose file to $BACKUP_FILE" mv docker-compose.yml "$BACKUP_FILE" fi log "šŸ“ Generating Docker Compose configuration..." create_env_file "$HOMELAB_DIR" # Source the service definitions if [[ -f "$SCRIPT_DIR/services" ]]; then source "$SCRIPT_DIR/services" # Export variables for service definitions export PUID PGID TIMEZONE MEDIA_DIR APPDATA_DIR # Generate complete compose file with all services generate_complete_compose "${SERVICES[@]}" track_step "compose_generated" # Create service-specific configurations create_service_configs "${SERVICES[@]}" log "āœ… Generated Docker Compose with ${#SERVICES[@]} services" else error_exit "Service definitions file not found: $SCRIPT_DIR/services" fi # Create networks if they don't exist create_docker_networks } # -------------------------------------------- # NETWORK CREATION # -------------------------------------------- create_docker_networks() { log "🌐 Creating Docker networks..." # Create traefik network if it doesn't exist if ! docker network ls --format "{{.Name}}" | grep -q "^traefik$"; then if docker network create traefik 2>/dev/null; then log "āœ… Created traefik network" else log "āš ļø Could not create traefik network (may already exist)" fi fi } # -------------------------------------------- # ENHANCED DEPLOYMENT WITH ROLLBACK # -------------------------------------------- deploy_services() { log "šŸš€ Starting deployment..." # Ensure we're in the correct directory # When running with sudo, use the original user's home directory local HOMELAB_DIR if [[ -n "$SUDO_USER" ]]; then HOMELAB_DIR="$(eval echo ~$SUDO_USER)/hops" else HOMELAB_DIR="$HOME/hops" fi if [[ ! -d "$HOMELAB_DIR" ]]; then error_exit_with_rollback "Homelab directory not found: $HOMELAB_DIR" fi cd "$HOMELAB_DIR" log "šŸ“ Working in directory: $(pwd)" # Set up error trap trap 'error_exit_with_rollback "Deployment failed at step: ${BASH_COMMAND}"' ERR # Pre-deployment checks log "šŸ” Running pre-deployment validation..." if ! docker info >/dev/null 2>&1; then error_exit_with_rollback "Docker daemon is not running or accessible" fi if ! docker compose config >/dev/null 2>&1; then error_exit_with_rollback "Generated docker-compose.yml is invalid" fi # Create required directories log "šŸ“ Creating required directories..." for svc in "${SERVICES[@]}"; do mkdir -p "${APPDATA_DIR}/${svc}" chown -R "$PUID:$PGID" "${APPDATA_DIR}/${svc}" 2>/dev/null || true done track_step "directories_created" # Handle macOS keychain access for Docker authentication if [[ "$OS_NAME_LOWER" == "macos" ]]; then local actual_user if [[ -n "$SUDO_USER" ]]; then actual_user="$SUDO_USER" else actual_user="$(whoami)" fi log "šŸ” Preparing Docker authentication for macOS..." # Try to unlock keychain if needed if ! sudo -u "$actual_user" security -v unlock-keychain ~/Library/Keychains/login.keychain-db 2>/dev/null; then log "āš ļø Could not unlock keychain automatically" log "šŸ’” If you have private Docker images, you may need to manually unlock keychain" log "šŸ’” Run: security -v unlock-keychain ~/Library/Keychains/login.keychain-db" else log "āœ… Keychain unlocked successfully" fi fi # Pull images with retry logic log "šŸ“„ Pulling container images..." local PULL_RETRIES=3 for attempt in $(seq 1 $PULL_RETRIES); do local pull_cmd if [[ "$OS_NAME_LOWER" == "macos" && -n "$SUDO_USER" ]]; then # Run as the actual user to access keychain pull_cmd="sudo -u $SUDO_USER docker compose pull" else pull_cmd="docker compose pull" fi if $pull_cmd 2>&1 | tee -a "$LOG_FILE"; then track_step "images_pulled" break elif [[ $attempt -eq $PULL_RETRIES ]]; then error_exit_with_rollback "Failed to pull images after $PULL_RETRIES attempts" else log "āš ļø Pull attempt $attempt failed, retrying in 10 seconds..." sleep 10 fi done # Start containers log "šŸ”„ Starting containers..." log "šŸ“„ Using docker-compose.yml in directory: $(pwd)" log "šŸ”§ Docker Compose configuration preview:" docker compose config --quiet 2>/dev/null || log "āš ļø Could not preview configuration" # Run docker compose up as the actual user on macOS to access keychain local up_cmd if [[ "$OS_NAME_LOWER" == "macos" && -n "$SUDO_USER" ]]; then up_cmd="sudo -u $SUDO_USER docker compose up -d" else up_cmd="docker compose up -d" fi if $up_cmd 2>&1 | tee -a "$LOG_FILE"; then track_step "containers_started" log "āœ… Container startup command completed successfully" # Wait a moment for containers to initialize sleep 5 # Check container status log "šŸ” Checking container status..." # Use consistent command execution local status_cmd logs_cmd if [[ "$OS_NAME_LOWER" == "macos" && -n "$SUDO_USER" ]]; then status_cmd="sudo -u $SUDO_USER docker compose ps" logs_cmd="sudo -u $SUDO_USER docker compose logs" else status_cmd="docker compose ps" logs_cmd="docker compose logs" fi $status_cmd --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" | tee -a "$LOG_FILE" # Count running containers local running_containers=$($status_cmd --filter "status=running" --format "{{.Names}}" | wc -l) local total_containers=$($status_cmd --format "{{.Names}}" | wc -l) log "šŸ“Š Container Status: $running_containers/$total_containers containers running" if [[ $running_containers -eq 0 ]]; then log "āš ļø No containers are running. Checking for errors..." $logs_cmd --tail=20 | tee -a "$LOG_FILE" warning "Containers were started but none are currently running. Check logs above." elif [[ $running_containers -lt $total_containers ]]; then log "āš ļø Some containers are not running. Checking logs..." $logs_cmd --tail=20 | tee -a "$LOG_FILE" warning "Not all containers are running. Check logs above." else log "āœ… All containers are running successfully" fi else log "āŒ Container startup failed. Checking status..." $status_cmd --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" | tee -a "$LOG_FILE" log "šŸ“‹ Recent container logs:" $logs_cmd --tail=50 | tee -a "$LOG_FILE" error_exit_with_rollback "Container startup failed" fi # Clear trap on success trap - ERR } # -------------------------------------------- # ENHANCED SERVICE VERIFICATION # -------------------------------------------- verify_service_health() { local service_name="$1" local max_wait=300 # 5 minutes local interval=10 log "šŸ” Waiting for $service_name to be healthy..." for ((i=0; i/dev/null || echo "none") case "$health" in "healthy") log "āœ… $service_name is healthy" return 0 ;; "starting") log "ā³ $service_name is starting... (${i}s elapsed)" ;; "unhealthy") log "āŒ $service_name is unhealthy" return 1 ;; "none") # No health check defined, check if container is running local state=$(docker inspect --format='{{.State.Status}}' "$service_name" 2>/dev/null || echo "unknown") if [[ "$state" == "running" ]]; then log "āœ… $service_name is running (no health check)" return 0 fi ;; esac sleep "$interval" done log "āš ļø $service_name health check timed out" return 1 } verify_services() { log "🩺 Verifying service health..." local FAILED_SERVICES=() for svc in "${SERVICES[@]}"; do if docker ps --format "{{.Names}}" | grep -qi "^${svc}$"; then if ! verify_service_health "$svc"; then FAILED_SERVICES+=("$svc") fi else log "āŒ $svc container not found" FAILED_SERVICES+=("$svc") fi done if [[ ${#FAILED_SERVICES[@]} -gt 0 ]]; then log "āš ļø Services requiring attention:" for svc in "${FAILED_SERVICES[@]}"; do log " • $svc - Check logs: docker logs $svc" done else log "āœ… All services are healthy" fi } # -------------------------------------------- # SECURITY SETUP # -------------------------------------------- setup_security() { log "šŸ”’ Applying security hardening..." # Secure sensitive files find "$APPDATA_DIR" -name "*.env" -exec chmod 600 {} \; 2>/dev/null || true find "$APPDATA_DIR" -name "*.key" -exec chmod 600 {} \; 2>/dev/null || true find "$APPDATA_DIR" -name "*.pem" -exec chmod 600 {} \; 2>/dev/null || true # Set secure permissions on homelab directory chmod 750 "$HOME/hops" log "āœ… Security hardening applied" } setup_firewall() { if [[ "$OS_NAME_LOWER" == "macos" ]]; then info "šŸ”„ Skipping firewall configuration on macOS (configure manually if needed)" return 0 fi if command -v ufw &>/dev/null; then log "šŸ”„ Configuring UFW firewall..." # Don't reset if already configured if ! ufw status | grep -q "Status: active"; then ufw --force reset >/dev/null 2>&1 ufw default deny incoming >/dev/null 2>&1 ufw default allow outgoing >/dev/null 2>&1 # Allow SSH ufw allow ssh >/dev/null 2>&1 fi # Allow service ports based on selection if [[ -f "$SCRIPT_DIR/services" ]]; then source "$SCRIPT_DIR/services" for svc in "${SERVICES[@]}"; do local ports=$(get_service_ports "$svc") for port in $ports; do # Skip UDP ports and handle TCP/UDP notation if [[ "$port" =~ /udp$ ]]; then local port_num="${port%/udp}" ufw allow "$port_num/udp" comment "$svc" >/dev/null 2>&1 else local port_num="${port%/tcp}" ufw allow "$port_num/tcp" comment "$svc" >/dev/null 2>&1 fi done done fi ufw --force enable >/dev/null 2>&1 log "āœ… Firewall configured" else log "ā„¹ļø UFW not available, skipping firewall configuration" fi } # -------------------------------------------- # MAIN INSTALLATION FLOW # -------------------------------------------- validate_system_requirements check_required_packages collect_user_configuration select_services check_all_ports "${SERVICES[@]}" # Install dependencies using abstraction info "šŸ“¦ Installing prerequisites..." # Define packages based on OS local required_packages if [[ "$OS_NAME_LOWER" == "macos" ]]; then required_packages=("curl" "openssl" "lsof" "httpd") else required_packages=("ca-certificates" "curl" "gnupg" "lsb-release" "lsof" "ufw" "fail2ban" "openssl" "apache2-utils") fi # Install each package for package in "${required_packages[@]}"; do if ! command -v "${package%%-*}" &>/dev/null; then install_package "$package" fi done # Install Docker if not present if ! check_docker_installation; then install_docker else success "āœ… Docker already installed and running" fi check_docker_compose_version # Ensure Docker daemon is running if ! is_service_running docker; then start_service docker enable_service docker fi setup_firewall generate_docker_compose deploy_services setup_security verify_services # -------------------------------------------- # FINAL SUMMARY # -------------------------------------------- echo -e "\nšŸŽ‰ HOPS Enhanced Deployment Complete!" echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "šŸ“‹ Deployment Summary:" echo -e "\nšŸ“‚ Configuration:" echo " • Homelab Directory: $HOME/hops" echo " • Application Data: $APPDATA_DIR" echo " • Media Directory: $MEDIA_DIR" echo " • User/Group: $RUNNING_USER ($PUID:$PGID)" echo " • Timezone: $TIMEZONE" echo " • Log File: $LOG_FILE" echo -e "\nšŸ” Security:" echo " • Generated secure passwords (see .env file)" echo " • Firewall configured with service-specific rules" echo " • File permissions hardened" echo -e "\nšŸ“± Deployed Services:" local service_count=0 for svc in "${SERVICES[@]}"; do if [[ -f "$SCRIPT_DIR/services" ]]; then source "$SCRIPT_DIR/services" local ports=$(get_service_ports "$svc") local main_port=$(echo $ports | cut -d' ' -f1) if [[ -n "$main_port" ]]; then echo " • $svc: http://$(get_primary_ip):$main_port" service_count=$((service_count + 1)) fi fi done echo -e "\nšŸ”§ Management Commands:" echo " • View all logs: docker compose logs -f" echo " • View service logs: docker compose logs -f [service]" echo " • Restart service: docker compose restart [service]" echo " • Stop services: docker compose down" echo " • Start services: docker compose up -d" echo " • Update services: docker compose pull && docker compose up -d" echo -e "\nšŸ“š Next Steps:" echo " 1. Access services using the URLs above" echo " 2. Change default passwords from .env file" echo " 3. Configure services according to your needs" echo " 4. Set up your media library paths" echo -e "\nšŸ“‹ Logs and troubleshooting: $LOG_FILE" echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log "šŸŽ‰ HOPS Enhanced deployment completed successfully!" return 0 } # Execute the main installation function install_hops