From 60bf2054bdb1503f9cc901c84a713c77610e745f Mon Sep 17 00:00:00 2001 From: Stephen Klein Date: Fri, 18 Jul 2025 05:07:32 -0400 Subject: [PATCH] Release v3.2.0: Major macOS compatibility improvements and bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Major macOS Compatibility Improvements - Enhanced Docker Desktop installation and startup process for macOS - Fixed Docker authentication with macOS keychain integration - Resolved user directory issues - all directories now use actual user home instead of root - Fixed password generation issues with missing shuf command and encoding errors - Improved container creation with proper working directory context - Enhanced healthcheck monitoring, particularly for Jellyseerr service ### Bug Fixes - Fixed Docker Compose version warnings by removing obsolete version attribute - Resolved container startup issues with proper directory navigation - Fixed file permission issues for config and media directories - Enhanced cross-platform path handling functions - Improved error handling and user feedback throughout installation process ### Code Quality Improvements - Updated all version references to v3.2.0 - Enhanced documentation with macOS-specific improvements - Improved cross-platform compatibility across all components - Better error messages and troubleshooting guidance This release significantly improves the macOS user experience and resolves numerous compatibility issues that were preventing successful installation and operation on macOS systems. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 19 ++++- hops | 4 +- install | 134 +++++++++++++++++++++++++---- lib/common.sh | 42 ++++++++- lib/privileges.sh | 2 - lib/security.sh | 12 +-- lib/system.sh | 213 +++++++++++++++++++++++++++++++++++++++++----- services | 13 +-- user-operations | 2 - 9 files changed, 384 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 8fa0ca0..827a514 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,27 @@ # HOPS - Homelab Orchestration Provisioning Script [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/badge/Version-3.1.0--beta-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-3.2.0-blue.svg)]() [![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-orange.svg)]() **HOPS** is a comprehensive, automated deployment solution for popular homelab applications. It simplifies the process of setting up and managing Docker-based services including media servers, download clients, monitoring tools, and more. -## 🆕 What's New in v3.1.0-beta +## 🆕 What's New in v3.2.0 -### Major Security Enhancements +### Major macOS Compatibility Improvements +- **🍎 Enhanced macOS Support**: Comprehensive fixes for macOS installation and operation +- **🔐 Keychain Integration**: Proper Docker authentication with macOS keychain +- **👤 User Directory Fixes**: All directories now use actual user home instead of root +- **🚀 Docker Desktop Integration**: Improved Docker Desktop startup and management +- **⚡ Better Error Handling**: Enhanced error messages and troubleshooting for macOS + +### Bug Fixes +- **🔧 Fixed password generation**: Resolved `shuf` command and encoding issues on macOS +- **🐳 Fixed container creation**: Resolved Docker Compose working directory issues +- **🏥 Fixed healthchecks**: Improved Jellyseerr and other service health monitoring +- **📁 Fixed file permissions**: Proper ownership of config and media directories + +### Previous in v3.1.0-beta - **🔐 Encrypted Secret Management**: All passwords and sensitive data now encrypted with AES-256 - **🛡️ Input Validation**: Comprehensive validation preventing injection attacks - **⚡ Privilege Separation**: Root operations separated from user operations diff --git a/hops b/hops index 102c840..51d604c 100755 --- a/hops +++ b/hops @@ -2,13 +2,13 @@ # HOPS - Homelab Orchestration Provisioning Script # Primary Management Script -# Version: 3.1.0 +# Version: 3.2.0 # Exit on any error set -e # Script version and metadata -readonly SCRIPT_VERSION="3.1.0-beta" +readonly SCRIPT_VERSION="3.2.0" readonly SCRIPT_NAME="HOPS" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/install b/install index acdd2d9..255082e 100755 --- a/install +++ b/install @@ -197,8 +197,9 @@ EOF mkdir -p "$MEDIA_DIR"/{movies,tv,music,books,downloads} mkdir -p "$APPDATA_DIR" - # Set ownership if not root - if [[ "$RUNNING_USER" != "root" ]]; then + # 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 @@ -336,17 +337,17 @@ EOF done # Fallback: construct a guaranteed compliant password - local upper=$(tr -dc 'A-Z' < /dev/urandom | head -c2) - local lower=$(tr -dc 'a-z' < /dev/urandom | head -c4) - local digits=$(tr -dc '0-9' < /dev/urandom | head -c2) - local symbols=$(tr -dc '!@#$%^&*' < /dev/urandom | head -c2) + 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=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c$remaining_length) - echo "${upper}${lower}${digits}${symbols}${remaining}" | fold -w1 | shuf | tr -d '\n' + local remaining=$(generate_chars 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' $remaining_length) + shuffle_string "${upper}${lower}${digits}${symbols}${remaining}" else - echo "${upper}${lower}${digits}${symbols}" + shuffle_string "${upper}${lower}${digits}${symbols}" fi } @@ -591,8 +592,22 @@ EOF # DOCKER COMPOSE FILE GENERATION # -------------------------------------------- generate_docker_compose() { - local HOMELAB_DIR="$HOME/hops" + # Use actual user's home directory, not root's + local actual_user_home + if [[ -n "$SUDO_USER" ]]; then + actual_user_home=$(eval echo "~$SUDO_USER") + else + actual_user_home="$HOME" + fi + + local HOMELAB_DIR="$actual_user_home/hops" mkdir -p "$HOMELAB_DIR" + + # Set ownership to actual user + if [[ -n "$SUDO_USER" ]]; then + chown -R "$PUID:$PGID" "$HOMELAB_DIR" 2>/dev/null || true + fi + cd "$HOMELAB_DIR" if [[ -f docker-compose.yml ]]; then @@ -649,6 +664,15 @@ EOF deploy_services() { log "🚀 Starting deployment..." + # Ensure we're in the correct directory + local HOMELAB_DIR="$HOME/hops" + 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 @@ -670,11 +694,40 @@ EOF 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 - if docker compose pull 2>&1 | tee -a "$LOG_FILE"; then + 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 @@ -687,11 +740,62 @@ EOF # Start containers log "🔄 Starting containers..." - if docker compose up -d 2>&1 | tee -a "$LOG_FILE"; then - track_step "containers_started" + 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 - log "❌ Some containers failed to start. Checking status..." - docker compose ps + 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 diff --git a/lib/common.sh b/lib/common.sh index 74f52df..556a531 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -2,7 +2,7 @@ # HOPS - Common Utility Functions # Shared functions for logging, error handling, and UI -# Version: 3.1.0-beta +# Version: 3.2.0 # Prevent multiple sourcing if [[ -n "${HOPS_COMMON_LOADED:-}" ]]; then @@ -101,7 +101,7 @@ show_hops_header() { local subtitle="$2" if [[ -z "$version" ]]; then - version="3.1.0" + version="3.2.0" fi clear @@ -266,4 +266,42 @@ get_available_port() { done echo "$port" +} + +# Cross-platform shuffle function (alternative to shuf) +shuffle_string() { + local input_string="$1" + local chars=() + local i + + # Convert string to array of characters + for (( i=0; i<${#input_string}; i++ )); do + chars+=("${input_string:$i:1}") + done + + # Fisher-Yates shuffle algorithm + for (( i=${#chars[@]}-1; i>0; i-- )); do + local j=$((RANDOM % (i+1))) + local temp="${chars[i]}" + chars[i]="${chars[j]}" + chars[j]="$temp" + done + + # Convert back to string + printf '%s' "${chars[@]}" +} + +# Cross-platform character generation (alternative to tr with better encoding) +generate_chars() { + local char_set="$1" + local count="$2" + local result="" + local i + + for (( i=0; i "$compose_file" << EOF -version: '3.8' - services: EOF diff --git a/lib/security.sh b/lib/security.sh index 4ef5f98..18fb324 100644 --- a/lib/security.sh +++ b/lib/security.sh @@ -75,23 +75,23 @@ generate_secure_password() { # Fallback: construct guaranteed compliant password debug "Using fallback password generation method" - local upper=$(tr -dc 'A-Z' < /dev/urandom | head -c2) - local lower=$(tr -dc 'a-z' < /dev/urandom | head -c4) - local digits=$(tr -dc '0-9' < /dev/urandom | head -c2) - local symbols=$(tr -dc '!@#$%^&*' < /dev/urandom | head -c2) + 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)) local password="" if [[ $remaining_length -gt 0 ]]; then - local remaining=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c$remaining_length) + local remaining=$(generate_chars 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' $remaining_length) password="${upper}${lower}${digits}${symbols}${remaining}" else password="${upper}${lower}${digits}${symbols}" fi # Shuffle the password - password=$(echo "$password" | fold -w1 | shuf | tr -d '\n') + password=$(shuffle_string "$password") echo "$password" } diff --git a/lib/system.sh b/lib/system.sh index 5590e07..1ecd951 100644 --- a/lib/system.sh +++ b/lib/system.sh @@ -379,15 +379,36 @@ run_system_checks() { # Get platform-specific default paths get_default_media_path() { if [[ "$OS_NAME_LOWER" == "macos" ]]; then - echo "/Users/$USER/hops/media" + # Use actual user, not root when running via sudo + local actual_user + if [[ -n "$SUDO_USER" ]]; then + actual_user="$SUDO_USER" + else + actual_user="$USER" + fi + echo "/Users/$actual_user/hops/media" else - echo "/mnt/media" + # Use actual user, not root when running via sudo + local actual_user + if [[ -n "$SUDO_USER" ]]; then + actual_user="$SUDO_USER" + else + actual_user="$USER" + fi + echo "/home/$actual_user/hops/media" fi } get_default_config_path() { if [[ "$OS_NAME_LOWER" == "macos" ]]; then - echo "/Users/$USER/hops/config" + # Use actual user, not root when running via sudo + local actual_user + if [[ -n "$SUDO_USER" ]]; then + actual_user="$SUDO_USER" + else + actual_user="$USER" + fi + echo "/Users/$actual_user/hops/config" else echo "/opt/appdata" fi @@ -395,9 +416,23 @@ get_default_config_path() { get_default_homelab_path() { if [[ "$OS_NAME_LOWER" == "macos" ]]; then - echo "/Users/$USER/hops" + # Use actual user, not root when running via sudo + local actual_user + if [[ -n "$SUDO_USER" ]]; then + actual_user="$SUDO_USER" + else + actual_user="$USER" + fi + echo "/Users/$actual_user/hops" else - echo "/home/$USER/hops" + # Use actual user, not root when running via sudo + local actual_user + if [[ -n "$SUDO_USER" ]]; then + actual_user="$SUDO_USER" + else + actual_user="$USER" + fi + echo "/home/$actual_user/hops" fi } @@ -872,35 +907,127 @@ install_docker() { actual_user="$(whoami)" fi - # Remove conflicting compose-bridge binary if it exists - if [[ -f "/usr/local/bin/compose-bridge" ]]; then - info "🗑️ Removing conflicting compose-bridge binary..." - rm -f "/usr/local/bin/compose-bridge" 2>/dev/null || true + # Check for and handle conflicting files + local conflicting_files=() + local potential_conflicts=( + "/usr/local/bin/compose-bridge" + "/usr/local/bin/docker" + "/usr/local/bin/docker-compose" + "/usr/local/bin/docker-credential-desktop" + "/usr/local/bin/docker-credential-osxkeychain" + "/usr/local/bin/hub-tool" + "/usr/local/bin/kubectl" + "/Applications/Docker.app" + "/usr/local/Caskroom/docker" + "/usr/local/Caskroom/docker-desktop" + "/opt/homebrew/Caskroom/docker" + "/opt/homebrew/Caskroom/docker-desktop" + ) + + for file in "${potential_conflicts[@]}"; do + if [[ -e "$file" ]]; then + conflicting_files+=("$file") + fi + done + + if [[ ${#conflicting_files[@]} -gt 0 ]]; then + warning "⚠️ Conflicting Docker files detected:" + for file in "${conflicting_files[@]}"; do + info " - $file" + done + echo + + read -p "❓ Do you want to remove these conflicting files before installing Docker? (y/N): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + for file in "${conflicting_files[@]}"; do + info "🗑️ Removing conflicting file: $file" + if [[ -d "$file" ]]; then + rm -rf "$file" 2>/dev/null || { + warning "Failed to remove $file - you may need to remove it manually" + } + else + rm -f "$file" 2>/dev/null || { + warning "Failed to remove $file - you may need to remove it manually" + } + fi + done + success "Conflicting files removed" + else + warning "Keeping existing files. Installation may fail due to conflicts." + fi fi - sudo -u "$actual_user" brew install --cask docker + # Install Docker Desktop with force flag to overwrite existing files + info "📦 Installing Docker Desktop via Homebrew..." + sudo -u "$actual_user" brew install --cask docker --force - # Start Docker Desktop + # Start Docker Desktop with user interaction guidance info "🚀 Starting Docker Desktop..." - open -a Docker + open -a "Docker Desktop" + + echo + warning "📋 IMPORTANT: Docker Desktop First-Time Setup Required" + warning "==============================================" + info "Docker Desktop will now open and may require user interaction:" + info " 1. ✅ Click 'Open' if macOS asks to confirm opening Docker Desktop" + info " 2. ✅ Accept the Docker Desktop license agreement" + info " 3. ✅ Complete the Docker Desktop onboarding/tutorial" + info " 4. ✅ Sign in to Docker Hub (optional, can skip)" + info " 5. ✅ Configure Docker settings if prompted" + info " 6. ⚠️ Do NOT close Docker Desktop during setup" + echo + warning "The installation will wait for you to complete these steps..." + echo + + # Wait for user to complete setup + read -p "❓ Press ENTER after you've completed the Docker Desktop setup and it's running..." + echo # Wait for Docker to start - info "⏳ Waiting for Docker Desktop to start (this may take a few minutes)..." - local max_wait=120 + info "⏳ Verifying Docker Desktop is running..." + local max_wait=60 local wait_time=0 + local docker_started=false - while ! docker info >/dev/null 2>&1; do - if [[ $wait_time -ge $max_wait ]]; then - error_exit "Docker Desktop failed to start within $max_wait seconds. Please start it manually and try again." + while [[ $wait_time -lt $max_wait ]]; do + # Try to connect to Docker daemon + if docker info >/dev/null 2>&1; then + docker_started=true + break fi - sleep 5 - ((wait_time += 5)) + sleep 2 + ((wait_time += 2)) echo -n "." done echo - success "Docker Desktop installed and started successfully" + + if [[ "$docker_started" == true ]]; then + success "✅ Docker Desktop is running and ready!" + else + warning "❌ Docker Desktop doesn't appear to be running." + warning "Please ensure Docker Desktop is:" + info " 1. Completely started (not just the app, but the Docker engine)" + info " 2. Shows 'Docker Desktop is running' in the menu bar" + info " 3. The whale icon in the menu bar is solid (not animated)" + echo + + read -p "❓ Try verification again? (y/N): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + if docker info >/dev/null 2>&1; then + success "✅ Docker Desktop is now running!" + else + error_exit "Docker Desktop still not responding. Please resolve the issue and re-run HOPS installation." + fi + else + error_exit "Installation cancelled. Please ensure Docker Desktop is running and try again." + fi + fi ;; "ubuntu"|"debian"|"linuxmint"|"mint") # Check for existing Docker installation @@ -946,6 +1073,52 @@ install_docker() { fi fi + # Check for and handle conflicting files + local conflicting_files=() + local potential_conflicts=( + "/usr/bin/docker" + "/usr/bin/docker-compose" + "/usr/local/bin/docker" + "/usr/local/bin/docker-compose" + "/etc/docker" + "/var/lib/docker" + ) + + for file in "${potential_conflicts[@]}"; do + if [[ -e "$file" ]]; then + conflicting_files+=("$file") + fi + done + + if [[ ${#conflicting_files[@]} -gt 0 ]]; then + warning "⚠️ Conflicting Docker files detected:" + for file in "${conflicting_files[@]}"; do + info " - $file" + done + echo + + read -p "❓ Do you want to remove these conflicting files before installing Docker? (y/N): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + for file in "${conflicting_files[@]}"; do + info "🗑️ Removing conflicting file: $file" + if [[ -d "$file" ]]; then + rm -rf "$file" 2>/dev/null || { + warning "Failed to remove $file - you may need to remove it manually" + } + else + rm -f "$file" 2>/dev/null || { + warning "Failed to remove $file - you may need to remove it manually" + } + fi + done + success "Conflicting files removed" + else + warning "Keeping existing files. Installation may fail due to conflicts." + fi + fi + # Install fresh Docker using the official script info "📦 Installing Docker Engine..." curl -fsSL https://get.docker.com | sh diff --git a/services b/services index 7aea12e..8351689 100755 --- a/services +++ b/services @@ -67,8 +67,8 @@ get_web_healthcheck() { test: ["CMD-SHELL", "curl -f http://localhost:$port$path || exit 1"] interval: 30s timeout: 10s - retries: 3 - start_period: 60s + retries: 5 + start_period: 90s EOF } @@ -612,7 +612,12 @@ $(get_restart_policy) volumes: - \${CONFIG_ROOT}/jellyseerr:/app/config $(get_timezone_mount) -$(get_web_healthcheck 5055) + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5055/ || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s $(get_homelab_network) labels: - "traefik.enable=true" @@ -944,8 +949,6 @@ generate_complete_compose() { # Start with the base compose structure cat > "$compose_file" < "$compose_file" << EOF -version: '3.8' - services: EOF