3cba0998a7
- Remove duplicate log/error_exit/warning/success/info from hops and uninstall; remove validate_password, generate_secure_password, create_docker_networks, validate_timezone from install. Single canonical copies now live in lib/common.sh, lib/security.sh, lib/validation.sh, and lib/docker.sh (A5, Q1) - Fix lib/docker.sh, lib/validation.sh, lib/security.sh to use LIB_DIR instead of SCRIPT_DIR so sourcing them inside a function does not clobber the caller's SCRIPT_DIR - Move SCRIPT_VERSION to lib/common.sh as the single source of truth; remove local declarations from hops, install, and uninstall - uninstall now sources lib/common.sh directly for standalone safety - validate_timezone updated to warn-and-default instead of error_exit - validate_password updated to handle empty input (return 3) - Update CHANGELOG and TODO to reflect resolved items (B1-B6, A1, A3, A5, Q1) and bump version to 1.0.1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
958 lines
34 KiB
Bash
Executable File
958 lines
34 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_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
# Load shared utilities
|
||
source "$SCRIPT_DIR/lib/common.sh"
|
||
source "$SCRIPT_DIR/lib/system.sh"
|
||
source "$SCRIPT_DIR/lib/validation.sh"
|
||
source "$SCRIPT_DIR/lib/security.sh"
|
||
source "$SCRIPT_DIR/lib/docker.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
|
||
TIMEZONE=$(validate_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"
|
||
}
|
||
|
||
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."
|
||
}
|
||
|
||
# --------------------------------------------
|
||
# 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
|
||
}
|
||
|
||
# --------------------------------------------
|
||
# 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<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/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 |