cd30d45fbf
### New Features - Added Caddy reverse proxy as a service option - Proper Docker container configuration with ports 80, 443, 2019 - Health check monitoring via Caddy admin API - Volume mounts for Caddyfile, site content, and data persistence - Integration with existing service selection and categorization ### Configuration Scope - HOPS provides: Container setup, volume mounts, networking, health checks - User provides: Caddyfile configuration, routing rules, SSL settings - Clear documentation about configuration responsibilities - Example Caddyfile provided in README ### Documentation Updates - Updated README.md with Caddy service listing and configuration guide - Updated CLAUDE.md with Caddy in supported services - Added comprehensive configuration scope documentation - Updated version references to 3.2.0 ### Technical Implementation - Added generate_caddy() function to services file - Integrated Caddy into service selection switch - Added port mapping for conflict detection (80, 443, 2019) - Categorized under proxy & security services - Added to available services listing This addition provides users with another reverse proxy option while maintaining HOPS' philosophy of providing infrastructure while allowing users to maintain control over their specific configuration needs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1039 lines
37 KiB
Bash
Executable File
1039 lines
37 KiB
Bash
Executable File
#!/bin/bash
|
||
|
||
install_hops() {
|
||
# Clear terminal at startup
|
||
clear
|
||
|
||
# Exit on any error
|
||
set -e
|
||
|
||
# Script version for update tracking
|
||
local SCRIPT_VERSION="3.2.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++))
|
||
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" <<EOF
|
||
# HOPS Environment Configuration
|
||
# Generated on $(date)
|
||
|
||
# ==============================================
|
||
# CORE CONFIGURATION
|
||
# ==============================================
|
||
|
||
# User Configuration
|
||
PUID=$PUID
|
||
PGID=$PGID
|
||
TZ=$TIMEZONE
|
||
|
||
# Directory Configuration
|
||
DATA_ROOT=$MEDIA_DIR
|
||
CONFIG_ROOT=$APPDATA_DIR
|
||
HOMELAB_DIR=$homelab_dir
|
||
|
||
# Network Configuration
|
||
DOCKER_SUBNET=172.20.0.0/16
|
||
|
||
# ==============================================
|
||
# SECURITY & AUTHENTICATION
|
||
# ==============================================
|
||
|
||
# Default Passwords (CHANGE THESE IMMEDIATELY!)
|
||
DEFAULT_ADMIN_PASSWORD=$(generate_secure_password 16)
|
||
DEFAULT_DB_PASSWORD=$(generate_secure_password 20)
|
||
|
||
# Optional: Custom domain for reverse proxy
|
||
# DOMAIN=yourdomain.com
|
||
|
||
# Optional: Email for Let's Encrypt
|
||
# ACME_EMAIL=admin@yourdomain.com
|
||
|
||
# ==============================================
|
||
# SERVICE-SPECIFIC CONFIGURATION
|
||
# ==============================================
|
||
|
||
# Plex Configuration (Get token from: https://www.plex.tv/claim/)
|
||
PLEX_CLAIM_TOKEN=
|
||
|
||
# Watchtower Email Notifications (Optional)
|
||
WATCHTOWER_EMAIL_FROM=
|
||
WATCHTOWER_EMAIL_TO=
|
||
WATCHTOWER_EMAIL_SERVER=
|
||
WATCHTOWER_EMAIL_PORT=587
|
||
WATCHTOWER_EMAIL_USER=
|
||
WATCHTOWER_EMAIL_PASSWORD=
|
||
|
||
# Traefik Let's Encrypt Email
|
||
ACME_EMAIL=admin@localhost
|
||
EOF
|
||
|
||
chmod 600 "$homelab_dir/.env"
|
||
log "✅ Environment file created with secure permissions"
|
||
}
|
||
|
||
# --------------------------------------------
|
||
# SERVICE SELECTION
|
||
# --------------------------------------------
|
||
select_services() {
|
||
echo -e "\n📺 CORE MEDIA TOOLS"
|
||
echo "1) Sonarr 2) Radarr 3) Lidarr 4) Readarr"
|
||
echo "5) Bazarr 6) Prowlarr 7) Tdarr 8) Huntarr"
|
||
|
||
echo -e "\n⬇️ DOWNLOAD CLIENTS"
|
||
echo "9) NZBGet 10) SABnzbd 11) Transmission 12) qBittorrent"
|
||
|
||
echo -e "\n🎞️ MEDIA SERVERS"
|
||
echo "13) Plex 14) Jellyfin 15) Jellystat 16) Emby"
|
||
|
||
echo -e "\n🎛️ REQUEST MANAGEMENT"
|
||
echo "17) Overseerr 18) Jellyseerr 19) Ombi"
|
||
|
||
echo -e "\n🔒 NETWORK & SECURITY"
|
||
echo "20) Traefik 21) Nginx Proxy Manager 22) Authelia"
|
||
|
||
echo -e "\n📈 MONITORING"
|
||
echo "23) Portainer 24) Watchtower 25) Uptime Kuma"
|
||
|
||
echo -e "\n📝 Select services (space-separated numbers, or 'all' for everything): "
|
||
read -a service_choices
|
||
|
||
# Function to map service numbers to names (bash 3.2 compatible)
|
||
get_service_name() {
|
||
case "$1" in
|
||
1) echo "sonarr" ;;
|
||
2) echo "radarr" ;;
|
||
3) echo "lidarr" ;;
|
||
4) echo "readarr" ;;
|
||
5) echo "bazarr" ;;
|
||
6) echo "prowlarr" ;;
|
||
7) echo "tdarr" ;;
|
||
8) echo "huntarr" ;;
|
||
9) echo "nzbget" ;;
|
||
10) echo "sabnzbd" ;;
|
||
11) echo "transmission" ;;
|
||
12) echo "qbittorrent" ;;
|
||
13) echo "plex" ;;
|
||
14) echo "jellyfin" ;;
|
||
15) echo "jellystat" ;;
|
||
16) echo "emby" ;;
|
||
17) echo "overseerr" ;;
|
||
18) echo "jellyseerr" ;;
|
||
19) echo "ombi" ;;
|
||
20) echo "traefik" ;;
|
||
21) echo "nginx-proxy-manager" ;;
|
||
22) echo "authelia" ;;
|
||
23) echo "portainer" ;;
|
||
24) echo "watchtower" ;;
|
||
25) echo "uptime-kuma" ;;
|
||
*) echo "" ;;
|
||
esac
|
||
}
|
||
|
||
SERVICES=()
|
||
if [[ "${service_choices[0]}" == "all" ]]; then
|
||
SERVICES=("sonarr" "radarr" "lidarr" "readarr" "bazarr" "prowlarr" "tdarr" "huntarr" "nzbget" "sabnzbd" "transmission" "qbittorrent" "plex" "jellyfin" "jellystat" "emby" "overseerr" "jellyseerr" "ombi" "traefik" "nginx-proxy-manager" "authelia" "portainer" "watchtower" "uptime-kuma")
|
||
log "🎯 Selected all services"
|
||
else
|
||
for choice in "${service_choices[@]}"; do
|
||
service_name=$(get_service_name "$choice")
|
||
[[ -n "$service_name" ]] && SERVICES+=("$service_name")
|
||
done
|
||
fi
|
||
|
||
if [[ ${#SERVICES[@]} -eq 0 ]]; then
|
||
error_exit "No valid services selected."
|
||
fi
|
||
|
||
log "✅ Selected services: ${SERVICES[*]}"
|
||
|
||
# Check for service dependencies and conflicts
|
||
check_service_dependencies
|
||
check_service_conflicts
|
||
}
|
||
|
||
# --------------------------------------------
|
||
# DEPENDENCY AND CONFLICT CHECKING
|
||
# --------------------------------------------
|
||
check_service_dependencies() {
|
||
# Source service definitions for dependency resolution
|
||
if [[ -f "$SCRIPT_DIR/services" ]]; then
|
||
source "$SCRIPT_DIR/services"
|
||
|
||
# Resolve dependencies
|
||
local all_services=($(resolve_dependencies "${SERVICES[@]}"))
|
||
local deps_added=()
|
||
|
||
for service in "${all_services[@]}"; do
|
||
if [[ ! " ${SERVICES[*]} " =~ " ${service} " ]]; then
|
||
deps_added+=("$service")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#deps_added[@]} -gt 0 ]]; then
|
||
log "📦 Adding dependencies: ${deps_added[*]}"
|
||
SERVICES=("${all_services[@]}")
|
||
fi
|
||
fi
|
||
|
||
# Check if any *arr services are selected without Prowlarr
|
||
local arr_services=(sonarr radarr lidarr readarr)
|
||
local has_arr=false
|
||
for arr in "${arr_services[@]}"; do
|
||
if [[ "${SERVICES[*]}" =~ $arr ]]; then
|
||
has_arr=true
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ $has_arr == true ]] && [[ ! "${SERVICES[*]}" =~ prowlarr ]]; then
|
||
echo -e "\n💡 Recommendation: You selected *arr services but not Prowlarr."
|
||
echo "Prowlarr manages indexers for all *arr applications."
|
||
echo -e "Add Prowlarr? [Y/n]: "
|
||
read -r add_prowlarr
|
||
if [[ ! "$add_prowlarr" =~ ^[Nn]$ ]]; then
|
||
SERVICES+=("prowlarr")
|
||
log "✅ Added Prowlarr"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
check_service_conflicts() {
|
||
local warnings=()
|
||
|
||
# Check for multiple media servers
|
||
local media_servers=(plex jellyfin emby)
|
||
local selected_media_servers=()
|
||
for server in "${media_servers[@]}"; do
|
||
if [[ "${SERVICES[*]}" =~ $server ]]; then
|
||
selected_media_servers+=("$server")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#selected_media_servers[@]} -gt 1 ]]; then
|
||
warnings+=("Multiple media servers selected: ${selected_media_servers[*]}")
|
||
fi
|
||
|
||
# Check for multiple reverse proxies
|
||
local reverse_proxies=(traefik nginx-proxy-manager)
|
||
local selected_proxies=()
|
||
for proxy in "${reverse_proxies[@]}"; do
|
||
if [[ "${SERVICES[*]}" =~ $proxy ]]; then
|
||
selected_proxies+=("$proxy")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#selected_proxies[@]} -gt 1 ]]; then
|
||
warnings+=("Multiple reverse proxies selected: ${selected_proxies[*]} (may conflict on ports 80/443)")
|
||
fi
|
||
|
||
# Display warnings if any
|
||
if [[ ${#warnings[@]} -gt 0 ]]; then
|
||
log "⚠️ Configuration warnings:"
|
||
for warning in "${warnings[@]}"; do
|
||
log " • $warning"
|
||
done
|
||
|
||
echo -e "\n⚠️ Continue with this configuration? [y/N]: "
|
||
read -r continue_choice
|
||
if [[ ! "$continue_choice" =~ ^[Yy]$ ]]; then
|
||
log "🚫 Installation cancelled by user"
|
||
exit 0
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# --------------------------------------------
|
||
# DOCKER COMPOSE FILE GENERATION
|
||
# --------------------------------------------
|
||
generate_docker_compose() {
|
||
# 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
|
||
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
|
||
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
|
||
|
||
# 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<max_wait; i+=interval)); do
|
||
local health=$(docker inspect --format='{{.State.Health.Status}}' "$service_name" 2>/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/hops_service_definitions.sh" ]]; then
|
||
source "$SCRIPT_DIR/hops_service_definitions.sh"
|
||
|
||
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++))
|
||
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 |