mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-06-13 19:15:15 +02:00
07ab5dc16e
Wrap the extension identifier in double quotes in the CREATE EXTENSION SQL call within setup_postgresql_db. This ensures extension names containing hyphens, mixed case, or other characters that require quoting are handled correctly and avoids syntax errors when creating extensions.
9727 lines
336 KiB
Bash
9727 lines
336 KiB
Bash
#!/bin/bash
|
||
|
||
# ==============================================================================
|
||
# HELPER FUNCTIONS FOR PACKAGE MANAGEMENT
|
||
# ==============================================================================
|
||
#
|
||
# This file provides unified helper functions for robust package installation
|
||
# and repository management across Debian/Ubuntu OS upgrades.
|
||
#
|
||
# Key Features:
|
||
# - Automatic retry logic for transient APT/network failures
|
||
# - Unified keyring cleanup from all 3 locations
|
||
# - Legacy installation cleanup (nvm, rbenv, rustup)
|
||
# - OS-upgrade-safe repository preparation
|
||
# - Service pattern matching for multi-version tools
|
||
# - Debug mode for troubleshooting (TOOLS_DEBUG=true)
|
||
#
|
||
# Usage in install scripts:
|
||
# source /dev/stdin <<< "$FUNCTIONS" # Load from build.func
|
||
# prepare_repository_setup "mysql"
|
||
# install_packages_with_retry "mysql-server" "mysql-client"
|
||
#
|
||
# Quick Reference (Core Helpers):
|
||
# cleanup_tool_keyrings() - Remove keyrings from all 3 locations
|
||
# stop_all_services() - Stop services by pattern (e.g. "php*-fpm")
|
||
# verify_tool_version() - Validate installed version matches expected
|
||
# cleanup_legacy_install() - Remove nvm, rbenv, rustup, etc.
|
||
# prepare_repository_setup() - Cleanup repos + keyrings + validate APT
|
||
# install_packages_with_retry() - Install with 3 retries and APT refresh
|
||
# upgrade_packages_with_retry() - Upgrade with 3 retries and APT refresh
|
||
# curl_with_retry() - Curl with retry logic and timeouts
|
||
#
|
||
# Debug Mode:
|
||
# TOOLS_DEBUG=true ./script.sh - Enable verbose output for troubleshooting
|
||
#
|
||
# ==============================================================================
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Debug helper - outputs to stderr when TOOLS_DEBUG is enabled
|
||
# Usage: debug_log "message"
|
||
# ------------------------------------------------------------------------------
|
||
debug_log() {
|
||
if [[ "${TOOLS_DEBUG:-false}" == "true" || "${TOOLS_DEBUG:-0}" == "1" || "${DEBUG:-0}" == "1" ]]; then
|
||
echo "[DEBUG] $*" >&2
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Robust curl wrapper with retry logic, timeouts, and error handling
|
||
#
|
||
# Usage:
|
||
# curl_with_retry "https://example.com/file" "/tmp/output"
|
||
# curl_with_retry "https://api.github.com/..." "-" | jq .
|
||
# CURL_RETRIES=5 curl_with_retry "https://slow.server/file" "/tmp/out"
|
||
#
|
||
# Parameters:
|
||
# $1 - URL to download
|
||
# $2 - Output file path (use "-" for stdout)
|
||
# $3 - (optional) Additional curl options as string
|
||
#
|
||
# Variables:
|
||
# CURL_RETRIES - Number of retries (default: 3)
|
||
# CURL_TIMEOUT - Max time per attempt in seconds (default: 60)
|
||
# CURL_CONNECT_TO - Connection timeout in seconds (default: 10)
|
||
#
|
||
# Returns: 0 on success, 1 on failure after all retries
|
||
# ------------------------------------------------------------------------------
|
||
curl_with_retry() {
|
||
local url="$1"
|
||
local output="${2:--}"
|
||
local extra_opts="${3:-}"
|
||
local retries="${CURL_RETRIES:-3}"
|
||
local timeout="${CURL_TIMEOUT:-60}"
|
||
local connect_timeout="${CURL_CONNECT_TO:-10}"
|
||
|
||
local attempt=1
|
||
local success=false
|
||
local backoff=1
|
||
|
||
# Extract hostname for DNS pre-check
|
||
local host
|
||
host=$(echo "$url" | sed -E 's|^https?://([^/:]+).*|\1|')
|
||
|
||
# DNS pre-check - fail fast if host is unresolvable
|
||
if ! getent hosts "$host" &>/dev/null; then
|
||
debug_log "DNS resolution failed for $host"
|
||
return 6
|
||
fi
|
||
|
||
while [[ $attempt -le $retries ]]; do
|
||
debug_log "curl attempt $attempt/$retries: $url"
|
||
|
||
local curl_cmd="curl -fsSL --connect-timeout $connect_timeout --max-time $timeout"
|
||
[[ -n "$extra_opts" ]] && curl_cmd="$curl_cmd $extra_opts"
|
||
|
||
if [[ "$output" == "-" ]]; then
|
||
if $curl_cmd "$url"; then
|
||
success=true
|
||
break
|
||
fi
|
||
else
|
||
if $curl_cmd -o "$output" "$url"; then
|
||
success=true
|
||
break
|
||
fi
|
||
fi
|
||
|
||
debug_log "curl attempt $attempt failed (timeout=${timeout}s), waiting ${backoff}s before retry..."
|
||
sleep "$backoff"
|
||
# Exponential backoff: 1, 2, 4, 8... capped at 30s
|
||
backoff=$((backoff * 2))
|
||
((backoff > 30)) && backoff=30
|
||
# Double --max-time on each retry so slow connections can finish
|
||
timeout=$((timeout * 2))
|
||
((attempt++))
|
||
done
|
||
|
||
if [[ "$success" == "true" ]]; then
|
||
debug_log "curl successful: $url"
|
||
return 0
|
||
else
|
||
debug_log "curl FAILED after $retries attempts: $url"
|
||
return 7
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Robust curl wrapper for API calls (returns HTTP code + body)
|
||
#
|
||
# Usage:
|
||
# response=$(curl_api_with_retry "https://api.github.com/repos/owner/repo/releases/latest")
|
||
# http_code=$(curl_api_with_retry "https://api.github.com/..." "/tmp/body.json")
|
||
#
|
||
# Parameters:
|
||
# $1 - URL to call
|
||
# $2 - (optional) Output file for body (default: stdout)
|
||
# $3 - (optional) Additional curl options as string
|
||
#
|
||
# Returns: HTTP status code, body in file or stdout
|
||
# ------------------------------------------------------------------------------
|
||
curl_api_with_retry() {
|
||
local url="$1"
|
||
local body_file="${2:-}"
|
||
local extra_opts="${3:-}"
|
||
local retries="${CURL_RETRIES:-3}"
|
||
local timeout="${CURL_TIMEOUT:-60}"
|
||
local connect_timeout="${CURL_CONNECT_TO:-10}"
|
||
|
||
local attempt=1
|
||
local http_code=""
|
||
|
||
while [[ $attempt -le $retries ]]; do
|
||
debug_log "curl API attempt $attempt/$retries: $url"
|
||
|
||
local curl_cmd="curl -fsSL --connect-timeout $connect_timeout --max-time $timeout -w '%{http_code}'"
|
||
[[ -n "$extra_opts" ]] && curl_cmd="$curl_cmd $extra_opts"
|
||
|
||
if [[ -n "$body_file" ]]; then
|
||
http_code=$($curl_cmd -o "$body_file" "$url" 2>/dev/null) || true
|
||
else
|
||
# Capture body and http_code separately
|
||
local tmp_body="/tmp/curl_api_body_$$"
|
||
http_code=$($curl_cmd -o "$tmp_body" "$url" 2>/dev/null) || true
|
||
if [[ -f "$tmp_body" ]]; then
|
||
cat "$tmp_body"
|
||
rm -f "$tmp_body"
|
||
fi
|
||
fi
|
||
|
||
# Success on 2xx codes
|
||
if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then
|
||
debug_log "curl API successful: $url (HTTP $http_code)"
|
||
echo "$http_code"
|
||
return 0
|
||
fi
|
||
|
||
debug_log "curl API attempt $attempt failed (HTTP $http_code, timeout=${timeout}s), waiting ${attempt}s..."
|
||
sleep "$attempt"
|
||
# Double --max-time on each retry so slow connections can finish
|
||
timeout=$((timeout * 2))
|
||
((attempt++))
|
||
done
|
||
|
||
debug_log "curl API FAILED after $retries attempts: $url"
|
||
echo "$http_code"
|
||
return 7
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Download and install GPG key with retry logic and validation
|
||
#
|
||
# Usage:
|
||
# download_gpg_key "https://example.com/key.gpg" "/etc/apt/keyrings/example.gpg"
|
||
# download_gpg_key "https://example.com/key.asc" "/etc/apt/keyrings/example.gpg" "dearmor"
|
||
#
|
||
# Parameters:
|
||
# $1 - URL to GPG key
|
||
# $2 - Output path for keyring file
|
||
# $3 - (optional) "dearmor" to convert ASCII-armored key to binary
|
||
#
|
||
# Features:
|
||
# - Auto-detects key format (binary vs armored)
|
||
# - Validates downloaded key
|
||
# - Multiple mirror fallback support
|
||
#
|
||
# Returns: 0 on success, 1 on failure
|
||
# ------------------------------------------------------------------------------
|
||
download_gpg_key() {
|
||
local url="$1"
|
||
local output="$2"
|
||
local mode="${3:-auto}" # auto, dearmor, or binary
|
||
local retries="${CURL_RETRIES:-3}"
|
||
local timeout="${CURL_TIMEOUT:-30}"
|
||
local temp_key
|
||
temp_key=$(mktemp)
|
||
|
||
mkdir -p "$(dirname "$output")"
|
||
|
||
local attempt=1
|
||
while [[ $attempt -le $retries ]]; do
|
||
debug_log "GPG key download attempt $attempt/$retries: $url"
|
||
|
||
# Download to temp file first
|
||
if ! curl -fsSL --connect-timeout 10 --max-time "$timeout" -o "$temp_key" "$url" 2>/dev/null; then
|
||
debug_log "GPG key download attempt $attempt failed, waiting ${attempt}s..."
|
||
sleep "$attempt"
|
||
((attempt++))
|
||
continue
|
||
fi
|
||
|
||
# Auto-detect key format if mode is auto
|
||
if [[ "$mode" == "auto" ]]; then
|
||
if file "$temp_key" 2>/dev/null | grep -qi "pgp\\|gpg\\|public key"; then
|
||
mode="binary"
|
||
elif grep -q "BEGIN PGP" "$temp_key" 2>/dev/null; then
|
||
mode="dearmor"
|
||
else
|
||
# Try to detect by extension
|
||
[[ "$url" == *.asc || "$url" == *.txt ]] && mode="dearmor" || mode="binary"
|
||
fi
|
||
fi
|
||
|
||
# Process based on mode
|
||
if [[ "$mode" == "dearmor" ]]; then
|
||
if gpg --dearmor --yes -o "$output" <"$temp_key" 2>/dev/null && [[ -s "$output" ]]; then
|
||
rm -f "$temp_key"
|
||
debug_log "GPG key installed (dearmored): $output"
|
||
return 0
|
||
fi
|
||
else
|
||
if mv "$temp_key" "$output" 2>/dev/null; then
|
||
chmod 644 "$output"
|
||
debug_log "GPG key installed: $output"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
debug_log "GPG key processing attempt $attempt failed"
|
||
sleep "$attempt"
|
||
((attempt++))
|
||
done
|
||
|
||
rm -f "$temp_key"
|
||
debug_log "GPG key download FAILED after $retries attempts: $url"
|
||
return 7
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Cache installed version to avoid repeated checks
|
||
# ------------------------------------------------------------------------------
|
||
cache_installed_version() {
|
||
local app="$1"
|
||
local version="$2"
|
||
mkdir -p /var/cache/app-versions
|
||
echo "$version" >"/var/cache/app-versions/${app}_version.txt"
|
||
}
|
||
|
||
get_cached_version() {
|
||
local app="$1"
|
||
mkdir -p /var/cache/app-versions
|
||
if [[ -f "/var/cache/app-versions/${app}_version.txt" ]]; then
|
||
cat "/var/cache/app-versions/${app}_version.txt"
|
||
return 0
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Clean up ALL keyring locations for a tool (unified helper)
|
||
# Usage: cleanup_tool_keyrings "mariadb" "mysql" "postgresql"
|
||
# ------------------------------------------------------------------------------
|
||
cleanup_tool_keyrings() {
|
||
local tool_patterns=("$@")
|
||
|
||
for pattern in "${tool_patterns[@]}"; do
|
||
rm -f /usr/share/keyrings/${pattern}*.gpg \
|
||
/etc/apt/keyrings/${pattern}*.gpg \
|
||
/etc/apt/trusted.gpg.d/${pattern}*.gpg 2>/dev/null || true
|
||
done
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Stop and disable all service instances matching a pattern
|
||
# Usage: stop_all_services "php*-fpm" "mysql" "mariadb"
|
||
# ------------------------------------------------------------------------------
|
||
stop_all_services() {
|
||
local service_patterns=("$@")
|
||
|
||
for pattern in "${service_patterns[@]}"; do
|
||
# Find all matching services (grep || true to handle no matches)
|
||
local services
|
||
services=$(systemctl list-units --type=service --all 2>/dev/null |
|
||
grep -oE "${pattern}[^ ]*\.service" 2>/dev/null | sort -u) || true
|
||
|
||
if [[ -n "$services" ]]; then
|
||
while read -r service; do
|
||
$STD systemctl stop "$service" 2>/dev/null || true
|
||
$STD systemctl disable "$service" 2>/dev/null || true
|
||
done <<<"$services"
|
||
fi
|
||
done
|
||
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Verify installed tool version matches expected version
|
||
# Returns: 0 if match, 1 if mismatch (with warning)
|
||
# Usage: verify_tool_version "nodejs" "22" "$(node -v | grep -oP '^v\K[0-9]+')"
|
||
# ------------------------------------------------------------------------------
|
||
verify_tool_version() {
|
||
local tool_name="$1"
|
||
local expected_version="$2"
|
||
local installed_version="$3"
|
||
|
||
# Extract major version for comparison
|
||
local expected_major="${expected_version%%.*}"
|
||
local installed_major="${installed_version%%.*}"
|
||
|
||
if [[ "$installed_major" != "$expected_major" ]]; then
|
||
msg_warn "$tool_name version mismatch: expected $expected_version, got $installed_version"
|
||
return 1
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Clean up legacy installation methods (nvm, rbenv, rustup, etc.)
|
||
# Usage: cleanup_legacy_install "nodejs" -> removes nvm
|
||
# ------------------------------------------------------------------------------
|
||
cleanup_legacy_install() {
|
||
local tool_name="$1"
|
||
|
||
case "$tool_name" in
|
||
nodejs | node)
|
||
if [[ -d "$HOME/.nvm" ]]; then
|
||
msg_info "Removing legacy nvm installation"
|
||
rm -rf "$HOME/.nvm" "$HOME/.npm" "$HOME/.bower" "$HOME/.config/yarn" 2>/dev/null || true
|
||
sed -i '/NVM_DIR/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null || true
|
||
msg_ok "Legacy nvm installation removed"
|
||
fi
|
||
;;
|
||
ruby)
|
||
if [[ -d "$HOME/.rbenv" ]]; then
|
||
msg_info "Removing legacy rbenv installation"
|
||
rm -rf "$HOME/.rbenv" 2>/dev/null || true
|
||
sed -i '/rbenv/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null || true
|
||
msg_ok "Legacy rbenv installation removed"
|
||
fi
|
||
;;
|
||
rust)
|
||
if [[ -d "$HOME/.cargo" ]] || [[ -d "$HOME/.rustup" ]]; then
|
||
msg_info "Removing legacy rustup installation"
|
||
rm -rf "$HOME/.cargo" "$HOME/.rustup" 2>/dev/null || true
|
||
sed -i '/cargo/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null || true
|
||
msg_ok "Legacy rustup installation removed"
|
||
fi
|
||
;;
|
||
go | golang)
|
||
if [[ -d "$HOME/go" ]]; then
|
||
msg_info "Removing legacy Go workspace"
|
||
# Keep user code, just remove GOPATH env
|
||
sed -i '/GOPATH/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null || true
|
||
msg_ok "Legacy Go workspace cleaned"
|
||
fi
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Unified repository preparation before setup
|
||
# Cleans up old repos, keyrings, and ensures APT is working
|
||
# Usage: prepare_repository_setup "mariadb" "mysql"
|
||
# ------------------------------------------------------------------------------
|
||
prepare_repository_setup() {
|
||
local repo_names=("$@")
|
||
|
||
# Clean up all old repository files
|
||
for repo in "${repo_names[@]}"; do
|
||
cleanup_old_repo_files "$repo"
|
||
done
|
||
|
||
# Clean up all keyrings
|
||
cleanup_tool_keyrings "${repo_names[@]}"
|
||
|
||
# Ensure APT is in working state
|
||
ensure_apt_working || return 100
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Install packages with retry logic
|
||
# Usage: install_packages_with_retry "mysql-server" "mysql-client"
|
||
# Features:
|
||
# - Automatic dpkg recovery on failure
|
||
# - Individual package fallback if batch fails
|
||
# - Dependency resolution with apt-get -f install
|
||
# ------------------------------------------------------------------------------
|
||
install_packages_with_retry() {
|
||
local packages=("$@")
|
||
local max_retries=3
|
||
local retry=0
|
||
|
||
# Pre-check: ensure dpkg is not in a broken state
|
||
if dpkg --audit 2>&1 | grep -q .; then
|
||
$STD dpkg --configure -a 2>/dev/null || true
|
||
fi
|
||
|
||
while [[ $retry -le $max_retries ]]; do
|
||
if DEBIAN_FRONTEND=noninteractive $STD apt install -y \
|
||
-o Dpkg::Options::="--force-confdef" \
|
||
-o Dpkg::Options::="--force-confold" \
|
||
"${packages[@]}" 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
|
||
retry=$((retry + 1))
|
||
if [[ $retry -le $max_retries ]]; then
|
||
msg_warn "Package installation failed, retrying ($retry/$max_retries)..."
|
||
|
||
# Progressive recovery steps based on retry count
|
||
case $retry in
|
||
1)
|
||
# First retry: just fix dpkg and update
|
||
$STD dpkg --configure -a 2>/dev/null || true
|
||
$STD apt update 2>/dev/null || true
|
||
;;
|
||
2)
|
||
# Second retry: fix broken dependencies
|
||
$STD apt --fix-broken install -y 2>/dev/null || true
|
||
$STD apt update 2>/dev/null || true
|
||
;;
|
||
3)
|
||
# Third retry: try installing packages one by one
|
||
local failed=()
|
||
for pkg in "${packages[@]}"; do
|
||
if ! $STD apt install -y "$pkg" 2>/dev/null; then
|
||
# Try with --fix-missing
|
||
if ! $STD apt install -y --fix-missing "$pkg" 2>/dev/null; then
|
||
failed+=("$pkg")
|
||
fi
|
||
fi
|
||
done
|
||
# If some packages installed, consider partial success
|
||
if [[ ${#failed[@]} -lt ${#packages[@]} ]]; then
|
||
if [[ ${#failed[@]} -gt 0 ]]; then
|
||
msg_warn "Partially installed. Failed packages: ${failed[*]}"
|
||
fi
|
||
return 0
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
sleep $((retry * 2))
|
||
fi
|
||
done
|
||
|
||
msg_error "Failed to install packages after $((max_retries + 1)) attempts: ${packages[*]}"
|
||
# Provide a quick diagnostic: check if package exists in any configured repo
|
||
local _os_codename
|
||
_os_codename=$(get_os_info codename)
|
||
local _unavailable=()
|
||
for _pkg in "${packages[@]}"; do
|
||
if ! apt-cache show "$_pkg" &>/dev/null; then
|
||
_unavailable+=("$_pkg")
|
||
fi
|
||
done
|
||
if [[ ${#_unavailable[@]} -gt 0 ]]; then
|
||
msg_error "Package(s) not found in any configured repository: ${_unavailable[*]}"
|
||
msg_error "Hint: These packages may not be available for '${_os_codename}'. Check repository configuration or package names."
|
||
else
|
||
msg_error "Hint: Package(s) exist in the repo but could not be installed — run 'apt-get install -f' inside the container or check for dependency conflicts."
|
||
fi
|
||
return 100
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Upgrade specific packages with retry logic
|
||
# Usage: upgrade_packages_with_retry "mariadb-server" "mariadb-client"
|
||
# ------------------------------------------------------------------------------
|
||
upgrade_packages_with_retry() {
|
||
local packages=("$@")
|
||
local max_retries=2
|
||
local retry=0
|
||
|
||
while [[ $retry -le $max_retries ]]; do
|
||
if DEBIAN_FRONTEND=noninteractive $STD apt install --only-upgrade -y \
|
||
-o Dpkg::Options::="--force-confdef" \
|
||
-o Dpkg::Options::="--force-confold" \
|
||
"${packages[@]}" 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
|
||
retry=$((retry + 1))
|
||
if [[ $retry -le $max_retries ]]; then
|
||
msg_warn "Package upgrade failed, retrying ($retry/$max_retries)..."
|
||
sleep 2
|
||
# Fix any interrupted dpkg operations before retry
|
||
$STD dpkg --configure -a 2>/dev/null || true
|
||
$STD apt update 2>/dev/null || true
|
||
fi
|
||
done
|
||
|
||
msg_error "Failed to upgrade packages after $((max_retries + 1)) attempts: ${packages[*]}"
|
||
msg_error "Hint: The package may be held back, have conflicting dependencies, or the repository is unreachable. Check 'apt-cache policy ${packages[*]}' inside the container."
|
||
return 100
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if tool is already installed and optionally verify exact version
|
||
# Returns: 0 if installed (with optional version match), 1 if not installed
|
||
# Usage: is_tool_installed "mariadb" "11.4" || echo "Not installed"
|
||
# ------------------------------------------------------------------------------
|
||
is_tool_installed() {
|
||
local tool_name="$1"
|
||
local required_version="${2:-}"
|
||
local installed_version=""
|
||
|
||
case "$tool_name" in
|
||
mariadb)
|
||
if command -v mariadb >/dev/null 2>&1; then
|
||
installed_version=$(mariadb --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
|
||
fi
|
||
;;
|
||
mysql)
|
||
if command -v mysql >/dev/null 2>&1; then
|
||
installed_version=$(mysql --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
|
||
fi
|
||
;;
|
||
mongodb | mongod)
|
||
if command -v mongod >/dev/null 2>&1; then
|
||
installed_version=$(mongod --version 2>/dev/null | awk '/db version/{print $3}' | cut -d. -f1,2)
|
||
fi
|
||
;;
|
||
node | nodejs)
|
||
if command -v node >/dev/null 2>&1; then
|
||
installed_version=$(node -v 2>/dev/null | grep -oP '^v\K[0-9]+' || true)
|
||
fi
|
||
;;
|
||
php)
|
||
if command -v php >/dev/null 2>&1; then
|
||
installed_version=$(php -v 2>/dev/null | awk '/^PHP/{print $2}' | cut -d. -f1,2)
|
||
fi
|
||
;;
|
||
postgres | postgresql)
|
||
if command -v psql >/dev/null 2>&1; then
|
||
installed_version=$(psql --version 2>/dev/null | awk '{print $3}' | cut -d. -f1)
|
||
fi
|
||
;;
|
||
ruby)
|
||
if command -v ruby >/dev/null 2>&1; then
|
||
installed_version=$(ruby --version 2>/dev/null | awk '{print $2}' | cut -d. -f1,2)
|
||
fi
|
||
;;
|
||
rust | rustc)
|
||
if command -v rustc >/dev/null 2>&1; then
|
||
installed_version=$(rustc --version 2>/dev/null | awk '{print $2}')
|
||
fi
|
||
;;
|
||
go | golang)
|
||
if command -v go >/dev/null 2>&1; then
|
||
installed_version=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
|
||
fi
|
||
;;
|
||
clickhouse)
|
||
if command -v clickhouse >/dev/null 2>&1; then
|
||
installed_version=$(clickhouse --version 2>/dev/null | awk '{print $2}')
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
if [[ -z "$installed_version" ]]; then
|
||
return 1 # Not installed
|
||
fi
|
||
|
||
if [[ -n "$required_version" && "$installed_version" != "$required_version" ]]; then
|
||
echo "$installed_version"
|
||
return 1 # Version mismatch
|
||
fi
|
||
|
||
echo "$installed_version"
|
||
return 0 # Installed and version matches (if specified)
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Remove old tool version completely (purge + cleanup repos)
|
||
# Usage: remove_old_tool_version "mariadb" "repository-name"
|
||
# ------------------------------------------------------------------------------
|
||
remove_old_tool_version() {
|
||
local tool_name="$1"
|
||
local repo_name="${2:-$tool_name}"
|
||
|
||
case "$tool_name" in
|
||
mariadb)
|
||
stop_all_services "mariadb"
|
||
$STD apt purge -y 'mariadb*' >/dev/null 2>&1 || true
|
||
cleanup_tool_keyrings "mariadb"
|
||
;;
|
||
mysql)
|
||
stop_all_services "mysql"
|
||
$STD apt purge -y 'mysql*' >/dev/null 2>&1 || true
|
||
rm -rf /var/lib/mysql 2>/dev/null || true
|
||
cleanup_tool_keyrings "mysql"
|
||
;;
|
||
mongodb)
|
||
stop_all_services "mongod"
|
||
$STD apt purge -y 'mongodb*' >/dev/null 2>&1 || true
|
||
rm -rf /var/lib/mongodb 2>/dev/null || true
|
||
cleanup_tool_keyrings "mongodb"
|
||
;;
|
||
node | nodejs)
|
||
$STD apt purge -y nodejs npm >/dev/null 2>&1 || true
|
||
# Clean up npm global modules
|
||
if command -v npm >/dev/null 2>&1; then
|
||
npm list -g 2>/dev/null | grep -oE '^ \S+' | awk '{print $1}' 2>/dev/null | while read -r module; do
|
||
npm uninstall -g "$module" >/dev/null 2>&1 || true
|
||
done || true
|
||
fi
|
||
cleanup_legacy_install "nodejs"
|
||
cleanup_tool_keyrings "nodesource"
|
||
;;
|
||
php)
|
||
stop_all_services "php.*-fpm"
|
||
$STD apt purge -y 'php*' >/dev/null 2>&1 || true
|
||
rm -rf /etc/php 2>/dev/null || true
|
||
cleanup_tool_keyrings "deb.sury.org-php" "php"
|
||
;;
|
||
postgresql)
|
||
stop_all_services "postgresql"
|
||
$STD apt purge -y 'postgresql*' >/dev/null 2>&1 || true
|
||
# Keep data directory for safety (can be removed manually if needed)
|
||
# rm -rf /var/lib/postgresql 2>/dev/null || true
|
||
cleanup_tool_keyrings "postgresql" "pgdg"
|
||
;;
|
||
java)
|
||
$STD apt purge -y 'temurin*' 'adoptium*' 'openjdk*' >/dev/null 2>&1 || true
|
||
cleanup_tool_keyrings "adoptium"
|
||
;;
|
||
ruby)
|
||
cleanup_legacy_install "ruby"
|
||
$STD apt purge -y 'ruby*' >/dev/null 2>&1 || true
|
||
;;
|
||
rust)
|
||
cleanup_legacy_install "rust"
|
||
;;
|
||
go | golang)
|
||
rm -rf /usr/local/go 2>/dev/null || true
|
||
cleanup_legacy_install "golang"
|
||
;;
|
||
clickhouse)
|
||
stop_all_services "clickhouse-server"
|
||
$STD apt purge -y 'clickhouse*' >/dev/null 2>&1 || true
|
||
rm -rf /var/lib/clickhouse 2>/dev/null || true
|
||
cleanup_tool_keyrings "clickhouse"
|
||
;;
|
||
esac
|
||
|
||
# Clean up old repository files (both .list and .sources)
|
||
cleanup_old_repo_files "$repo_name"
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Determine if tool update/upgrade is needed
|
||
# Returns: 0 (update needed), 1 (already up-to-date)
|
||
# Usage: if should_update_tool "mariadb" "11.4"; then ... fi
|
||
# ------------------------------------------------------------------------------
|
||
should_update_tool() {
|
||
local tool_name="$1"
|
||
local target_version="$2"
|
||
local current_version=""
|
||
|
||
# Get currently installed version
|
||
current_version=$(is_tool_installed "$tool_name" 2>/dev/null) || return 0 # Not installed = needs install
|
||
|
||
# If versions are identical, no update needed
|
||
if [[ "$current_version" == "$target_version" ]]; then
|
||
return 1 # No update needed
|
||
fi
|
||
|
||
return 0 # Update needed
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Unified repository management for tools
|
||
# Handles adding, updating, and verifying tool repositories
|
||
# Usage: manage_tool_repository "mariadb" "11.4" "https://repo..." "GPG_key_url"
|
||
# Supports: mariadb, mongodb, nodejs, postgresql, php, mysql
|
||
# ------------------------------------------------------------------------------
|
||
manage_tool_repository() {
|
||
local tool_name="$1"
|
||
local version="$2"
|
||
local repo_url="$3"
|
||
local gpg_key_url="${4:-}"
|
||
local distro_id repo_component suite
|
||
|
||
distro_id=$(get_os_info id)
|
||
|
||
case "$tool_name" in
|
||
mariadb)
|
||
if [[ -z "$repo_url" || -z "$gpg_key_url" ]]; then
|
||
msg_error "MariaDB repository requires repo_url and gpg_key_url"
|
||
return 65
|
||
fi
|
||
|
||
# Clean old repos first
|
||
cleanup_old_repo_files "mariadb"
|
||
|
||
# Get suite for fallback handling
|
||
local distro_codename
|
||
distro_codename=$(get_os_info codename)
|
||
suite=$(get_fallback_suite "$distro_id" "$distro_codename" "$repo_url/$distro_id")
|
||
|
||
# Setup new repository using deb822 format
|
||
setup_deb822_repo \
|
||
"mariadb" \
|
||
"$gpg_key_url" \
|
||
"$repo_url/$distro_id" \
|
||
"$suite" \
|
||
"main"
|
||
return 0
|
||
;;
|
||
|
||
mongodb)
|
||
if [[ -z "$repo_url" || -z "$gpg_key_url" ]]; then
|
||
msg_error "MongoDB repository requires repo_url and gpg_key_url"
|
||
return 65
|
||
fi
|
||
|
||
# Clean old repos first
|
||
cleanup_old_repo_files "mongodb"
|
||
|
||
# Import GPG key with retry logic
|
||
if ! download_gpg_key "$gpg_key_url" "/etc/apt/keyrings/mongodb-server-${version}.gpg" "dearmor"; then
|
||
msg_error "Failed to download MongoDB GPG key"
|
||
msg_error "Hint: Check connectivity to downloads.mongodb.org or verify MongoDB version ${version} is still available"
|
||
return 7
|
||
fi
|
||
chmod 644 "/etc/apt/keyrings/mongodb-server-${version}.gpg"
|
||
|
||
# Setup repository
|
||
local distro_codename
|
||
distro_codename=$(get_os_info codename)
|
||
|
||
# Suite mapping with fallback for newer releases not yet supported by upstream
|
||
if [[ "$distro_id" == "debian" ]]; then
|
||
case "$distro_codename" in
|
||
trixie | forky | sid)
|
||
# Testing/unstable releases fallback to latest stable suite
|
||
suite="bookworm"
|
||
;;
|
||
bookworm)
|
||
suite="bookworm"
|
||
;;
|
||
bullseye)
|
||
suite="bullseye"
|
||
;;
|
||
*)
|
||
# Unknown release: fallback to latest stable suite
|
||
msg_warn "Unknown Debian release '${distro_codename}', using bookworm"
|
||
suite="bookworm"
|
||
;;
|
||
esac
|
||
elif [[ "$distro_id" == "ubuntu" ]]; then
|
||
case "$distro_codename" in
|
||
oracular | plucky)
|
||
# Newer releases fallback to latest LTS
|
||
suite="noble"
|
||
;;
|
||
noble)
|
||
suite="noble"
|
||
;;
|
||
jammy)
|
||
suite="jammy"
|
||
;;
|
||
focal)
|
||
suite="focal"
|
||
;;
|
||
*)
|
||
# Unknown release: fallback to latest LTS
|
||
msg_warn "Unknown Ubuntu release '${distro_codename}', using noble"
|
||
suite="noble"
|
||
;;
|
||
esac
|
||
else
|
||
# For other distros, try generic fallback
|
||
suite=$(get_fallback_suite "$distro_id" "$distro_codename" "$repo_url")
|
||
fi
|
||
|
||
repo_component="main"
|
||
[[ "$distro_id" == "ubuntu" ]] && repo_component="multiverse"
|
||
|
||
cat <<EOF >/etc/apt/sources.list.d/mongodb-org-${version}.sources
|
||
Types: deb
|
||
URIs: ${repo_url}
|
||
Suites: ${suite}/mongodb-org/${version}
|
||
Components: ${repo_component}
|
||
Architectures: $(dpkg --print-architecture)
|
||
Signed-By: /etc/apt/keyrings/mongodb-server-${version}.gpg
|
||
EOF
|
||
return 0
|
||
;;
|
||
|
||
nodejs)
|
||
if [[ -z "$repo_url" || -z "$gpg_key_url" ]]; then
|
||
msg_error "Node.js repository requires repo_url and gpg_key_url"
|
||
return 65
|
||
fi
|
||
|
||
cleanup_old_repo_files "nodesource"
|
||
|
||
# NodeSource uses deb822 format with GPG from repo
|
||
local distro_codename
|
||
distro_codename=$(get_os_info codename)
|
||
|
||
# Download GPG key from NodeSource with retry logic
|
||
if ! download_gpg_key "$gpg_key_url" "/etc/apt/keyrings/nodesource.gpg" "dearmor"; then
|
||
msg_error "Failed to import NodeSource GPG key"
|
||
return 7
|
||
fi
|
||
|
||
cat <<EOF >/etc/apt/sources.list.d/nodesource.sources
|
||
Types: deb
|
||
URIs: $repo_url
|
||
Suites: nodistro
|
||
Components: main
|
||
Architectures: $(dpkg --print-architecture)
|
||
Signed-By: /etc/apt/keyrings/nodesource.gpg
|
||
EOF
|
||
return 0
|
||
;;
|
||
|
||
php)
|
||
if [[ -z "$gpg_key_url" ]]; then
|
||
msg_error "PHP repository requires gpg_key_url"
|
||
return 65
|
||
fi
|
||
|
||
cleanup_old_repo_files "php"
|
||
|
||
# Download and install keyring with retry logic
|
||
if ! curl_with_retry "$gpg_key_url" "/tmp/debsuryorg-archive-keyring.deb"; then
|
||
msg_error "Failed to download PHP keyring"
|
||
msg_error "Hint: Check connectivity to packages.sury.org (packages.sury.org/php)"
|
||
return 7
|
||
fi
|
||
# Don't use /dev/null redirection for dpkg as it may use background processes
|
||
dpkg -i /tmp/debsuryorg-archive-keyring.deb >>"$(get_active_logfile)" 2>&1 || {
|
||
msg_error "Failed to install PHP keyring"
|
||
rm -f /tmp/debsuryorg-archive-keyring.deb
|
||
return 100
|
||
}
|
||
rm -f /tmp/debsuryorg-archive-keyring.deb
|
||
|
||
# Setup repository
|
||
local distro_codename
|
||
distro_codename=$(get_os_info codename)
|
||
cat <<EOF >/etc/apt/sources.list.d/php.sources
|
||
Types: deb
|
||
URIs: https://packages.sury.org/php
|
||
Suites: $distro_codename
|
||
Components: main
|
||
Architectures: $(dpkg --print-architecture)
|
||
Signed-By: /usr/share/keyrings/deb.sury.org-php.gpg
|
||
EOF
|
||
return 0
|
||
;;
|
||
|
||
postgresql)
|
||
if [[ -z "$gpg_key_url" ]]; then
|
||
msg_error "PostgreSQL repository requires gpg_key_url"
|
||
return 65
|
||
fi
|
||
|
||
cleanup_old_repo_files "postgresql"
|
||
|
||
# Import PostgreSQL key with retry logic
|
||
if ! download_gpg_key "$gpg_key_url" "/etc/apt/keyrings/postgresql.gpg" "dearmor"; then
|
||
msg_error "Failed to import PostgreSQL GPG key"
|
||
return 7
|
||
fi
|
||
|
||
# Setup repository
|
||
local distro_codename
|
||
distro_codename=$(get_os_info codename)
|
||
cat <<EOF >/etc/apt/sources.list.d/postgresql.sources
|
||
Types: deb
|
||
URIs: http://apt.postgresql.org/pub/repos/apt
|
||
Suites: $distro_codename-pgdg
|
||
Components: main
|
||
Architectures: $(dpkg --print-architecture)
|
||
Signed-By: /etc/apt/keyrings/postgresql.gpg
|
||
EOF
|
||
return 0
|
||
;;
|
||
|
||
*)
|
||
msg_error "Unknown tool repository: $tool_name"
|
||
return 65
|
||
;;
|
||
esac
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Unified package upgrade function (with apt update caching)
|
||
# ------------------------------------------------------------------------------
|
||
upgrade_package() {
|
||
local package="$1"
|
||
|
||
# Use same caching logic as ensure_dependencies
|
||
local apt_cache_file="/var/cache/apt-update-timestamp"
|
||
local current_time=$(date +%s)
|
||
local last_update=0
|
||
|
||
if [[ -f "$apt_cache_file" ]]; then
|
||
last_update=$(cat "$apt_cache_file" 2>/dev/null || echo 0)
|
||
fi
|
||
|
||
if ((current_time - last_update > 300)); then
|
||
$STD apt update || {
|
||
msg_warn "APT update failed in upgrade_package - continuing with cached packages"
|
||
}
|
||
echo "$current_time" >"$apt_cache_file"
|
||
fi
|
||
|
||
$STD apt install --only-upgrade -y "$package" || {
|
||
msg_warn "Failed to upgrade $package"
|
||
return 100
|
||
}
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Repository availability check with caching
|
||
# ------------------------------------------------------------------------------
|
||
# Note: Must use -gA (global) because tools.func is sourced inside update_os()
|
||
# function scope. Plain 'declare -A' would create a local variable that gets
|
||
# destroyed when update_os() returns, causing "unbound variable" errors later
|
||
# when setup_postgresql/verify_repo_available tries to access the cache key.
|
||
declare -gA _REPO_CACHE 2>/dev/null || declare -A _REPO_CACHE 2>/dev/null || true
|
||
|
||
verify_repo_available() {
|
||
local repo_url="$1"
|
||
local suite="$2"
|
||
local cache_key="${repo_url}|${suite}"
|
||
local cache_ttl=300 # 5 minutes
|
||
|
||
# Check cache first (avoid repeated HTTP requests)
|
||
if [[ -n "${_REPO_CACHE[$cache_key]:-}" ]]; then
|
||
local cached_time cached_result
|
||
cached_time=$(echo "${_REPO_CACHE[$cache_key]}" | cut -d'|' -f1)
|
||
cached_result=$(echo "${_REPO_CACHE[$cache_key]}" | cut -d'|' -f2)
|
||
if (($(date +%s) - cached_time < cache_ttl)); then
|
||
[[ "$cached_result" == "1" ]] && return 0 || return 1
|
||
fi
|
||
fi
|
||
|
||
# Perform actual check with short timeout
|
||
local result=1
|
||
if curl -fsSL --max-time 5 --connect-timeout 3 "${repo_url}/dists/${suite}/Release" &>/dev/null; then
|
||
result=0
|
||
fi
|
||
|
||
# Cache the result
|
||
_REPO_CACHE[$cache_key]="$(date +%s)|$result"
|
||
|
||
return $result
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Ensure dependencies are installed (with apt/apk update caching)
|
||
# Supports both Debian (apt/dpkg) and Alpine (apk) systems
|
||
# ------------------------------------------------------------------------------
|
||
ensure_dependencies() {
|
||
local deps=("$@")
|
||
local missing=()
|
||
|
||
# Detect Alpine Linux
|
||
if [[ -f /etc/alpine-release ]]; then
|
||
for dep in "${deps[@]}"; do
|
||
if command -v "$dep" &>/dev/null; then
|
||
continue
|
||
fi
|
||
if apk info -e "$dep" &>/dev/null; then
|
||
continue
|
||
fi
|
||
missing+=("$dep")
|
||
done
|
||
|
||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||
$STD apk add --no-cache "${missing[@]}" || {
|
||
local failed=()
|
||
for pkg in "${missing[@]}"; do
|
||
if ! $STD apk add --no-cache "$pkg" 2>/dev/null; then
|
||
failed+=("$pkg")
|
||
fi
|
||
done
|
||
if [[ ${#failed[@]} -gt 0 ]]; then
|
||
msg_error "Failed to install dependencies: ${failed[*]}"
|
||
return 1
|
||
fi
|
||
}
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# Debian/Ubuntu: Fast batch check using dpkg-query
|
||
local installed_pkgs
|
||
installed_pkgs=$(dpkg-query -W -f='${Package}\n' 2>/dev/null | sort -u)
|
||
|
||
for dep in "${deps[@]}"; do
|
||
# First check if command exists (for binaries like jq, curl)
|
||
if command -v "$dep" &>/dev/null; then
|
||
continue
|
||
fi
|
||
# Then check if package is installed
|
||
if echo "$installed_pkgs" | grep -qx "$dep"; then
|
||
continue
|
||
fi
|
||
missing+=("$dep")
|
||
done
|
||
|
||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||
# Only run apt update if not done recently (within last 5 minutes)
|
||
local apt_cache_file="/var/cache/apt-update-timestamp"
|
||
local current_time
|
||
current_time=$(date +%s)
|
||
local last_update=0
|
||
|
||
if [[ -f "$apt_cache_file" ]]; then
|
||
last_update=$(cat "$apt_cache_file" 2>/dev/null || echo 0)
|
||
fi
|
||
|
||
if ((current_time - last_update > 300)); then
|
||
# Ensure orphaned sources are cleaned before updating
|
||
cleanup_orphaned_sources 2>/dev/null || true
|
||
|
||
if ! $STD apt update; then
|
||
ensure_apt_working || return 100
|
||
fi
|
||
echo "$current_time" >"$apt_cache_file"
|
||
fi
|
||
|
||
$STD apt install -y "${missing[@]}" || {
|
||
# Fallback: try installing one by one to identify problematic package
|
||
local failed=()
|
||
for pkg in "${missing[@]}"; do
|
||
if ! $STD apt install -y "$pkg" 2>/dev/null; then
|
||
failed+=("$pkg")
|
||
fi
|
||
done
|
||
if [[ ${#failed[@]} -gt 0 ]]; then
|
||
msg_error "Failed to install dependencies: ${failed[*]}"
|
||
return 100
|
||
fi
|
||
}
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Smart version comparison
|
||
# ------------------------------------------------------------------------------
|
||
version_gt() {
|
||
test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get system architecture (normalized)
|
||
# ------------------------------------------------------------------------------
|
||
get_system_arch() {
|
||
local arch_type="${1:-dpkg}" # dpkg, uname, or both
|
||
local arch
|
||
|
||
case "$arch_type" in
|
||
dpkg)
|
||
arch=$(dpkg --print-architecture 2>/dev/null)
|
||
;;
|
||
uname)
|
||
arch=$(uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
;;
|
||
both | *)
|
||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
;;
|
||
esac
|
||
|
||
echo "$arch"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Create temporary directory with automatic cleanup
|
||
# ------------------------------------------------------------------------------
|
||
create_temp_dir() {
|
||
local tmp_dir=$(mktemp -d)
|
||
# Set trap to cleanup on EXIT, ERR, INT, TERM
|
||
trap "rm -rf '$tmp_dir'" EXIT ERR INT TERM
|
||
echo "$tmp_dir"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# create_backup <path> [<path> ...] / restore_backup
|
||
#
|
||
# Standardized data backup helpers for update_script(). They replace the
|
||
# hand-rolled "cp/mv to a sibling dir, update, copy back, rm" dance that is
|
||
# duplicated across the ct/*.sh update functions.
|
||
#
|
||
# create_backup <path> [<path> ...]
|
||
# - Copies each given file/directory into a persistent store at
|
||
# /opt/<NSAPP>.backup, mirroring its absolute path inside the store, and
|
||
# records it in a manifest so restore_backup needs no arguments.
|
||
# - Idempotent: if a store from a previous (failed) run already exists, it is
|
||
# left untouched and no new backup is taken. This keeps the last-known-good
|
||
# data instead of overwriting it with now-partially-updated data on retry.
|
||
# - Missing source paths are skipped with a warning (not fatal).
|
||
# - Aborts the update on copy failure: if any file/dir cannot be backed up,
|
||
# the half-written store is removed and the script exits, so the update
|
||
# never runs against unprotected data (and a retry re-attempts a clean
|
||
# backup rather than skipping it).
|
||
#
|
||
# restore_backup
|
||
# - Copies every path recorded in the manifest back to its origin (replacing
|
||
# whatever the update left there), then deletes the store.
|
||
# - No-op (with a warning) if no store exists.
|
||
#
|
||
# Override the store location with BACKUP_DIR if the default does not fit.
|
||
# ------------------------------------------------------------------------------
|
||
create_backup() {
|
||
local store manifest path dest
|
||
store="${BACKUP_DIR:-/opt/${NSAPP:-app}.backup}"
|
||
manifest="${store}/.manifest"
|
||
|
||
[[ $# -eq 0 ]] && {
|
||
msg_warn "create_backup called without any paths"
|
||
return 0
|
||
}
|
||
|
||
if [[ -f "$manifest" ]]; then
|
||
msg_ok "Existing backup found at ${store}, skipping backup"
|
||
return 0
|
||
fi
|
||
|
||
msg_info "Backing up data"
|
||
if ! mkdir -p "$store" || ! : >"$manifest"; then
|
||
msg_error "Backup failed: could not create store at ${store} - aborting update"
|
||
rm -rf "$store"
|
||
exit 1
|
||
fi
|
||
for path in "$@"; do
|
||
path="${path%/}"
|
||
if [[ ! -e "$path" ]]; then
|
||
msg_warn "Skipping backup of '${path}' (not found)"
|
||
continue
|
||
fi
|
||
dest="${store}/files${path}"
|
||
if ! mkdir -p "$(dirname "$dest")" || ! cp -a "$path" "$dest"; then
|
||
msg_error "Backup of '${path}' failed - aborting update"
|
||
rm -rf "$store"
|
||
exit 1
|
||
fi
|
||
echo "$path" >>"$manifest"
|
||
done
|
||
msg_ok "Backed up data to ${store}"
|
||
}
|
||
|
||
restore_backup() {
|
||
local store manifest path src
|
||
store="${BACKUP_DIR:-/opt/${NSAPP:-app}.backup}"
|
||
manifest="${store}/.manifest"
|
||
|
||
if [[ ! -f "$manifest" ]]; then
|
||
msg_warn "No backup found to restore"
|
||
return 0
|
||
fi
|
||
|
||
msg_info "Restoring data"
|
||
while IFS= read -r path; do
|
||
[[ -z "$path" ]] && continue
|
||
src="${store}/files${path}"
|
||
[[ -e "$src" ]] || continue
|
||
mkdir -p "$(dirname "$path")"
|
||
rm -rf "$path"
|
||
cp -a "$src" "$path"
|
||
done <"$manifest"
|
||
rm -rf "$store"
|
||
msg_ok "Restored data"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if package is installed (supports both Debian and Alpine)
|
||
# ------------------------------------------------------------------------------
|
||
is_package_installed() {
|
||
local package="$1"
|
||
if [[ -f /etc/alpine-release ]]; then
|
||
apk info -e "$package" &>/dev/null
|
||
else
|
||
dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "^install ok installed$"
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# validate_github_token()
|
||
# Checks a GitHub token via the /user endpoint.
|
||
# Prints a status message and returns:
|
||
# 0 - token is valid
|
||
# 1 - token is invalid / expired (HTTP 401)
|
||
# 2 - token has no public repo scope (HTTP 200 but missing scope)
|
||
# 3 - network/API error
|
||
# Also reports expiry date if the token carries an x-oauth-expiry header.
|
||
# ------------------------------------------------------------------------------
|
||
validate_github_token() {
|
||
local token="${1:-${GITHUB_TOKEN:-}}"
|
||
[[ -z "$token" ]] && return 3
|
||
|
||
local response headers http_code expiry_date scopes
|
||
headers=$(mktemp)
|
||
response=$(curl -sSL -w "%{http_code}" \
|
||
-D "$headers" \
|
||
-o /dev/null \
|
||
-H "Authorization: Bearer $token" \
|
||
-H "Accept: application/vnd.github+json" \
|
||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||
"https://api.github.com/user" 2>/dev/null) || {
|
||
rm -f "$headers"
|
||
return 3
|
||
}
|
||
http_code="$response"
|
||
|
||
# Read expiry header (fine-grained PATs carry this)
|
||
expiry_date=$(grep -i '^github-authentication-token-expiration:' "$headers" |
|
||
sed 's/.*: *//' | tr -d '\r\n' || true)
|
||
# Read token scopes (classic PATs)
|
||
scopes=$(grep -i '^x-oauth-scopes:' "$headers" |
|
||
sed 's/.*: *//' | tr -d '\r\n' || true)
|
||
rm -f "$headers"
|
||
|
||
case "$http_code" in
|
||
200)
|
||
if [[ -n "$expiry_date" ]]; then
|
||
msg_ok "GitHub token is valid (expires: $expiry_date)."
|
||
else
|
||
msg_ok "GitHub token is valid (no expiry / fine-grained PAT)."
|
||
fi
|
||
# Warn if classic PAT has no public_repo scope
|
||
if [[ -n "$scopes" && "$scopes" != *"public_repo"* && "$scopes" != *"repo"* ]]; then
|
||
msg_warn "Token has no 'public_repo' scope - private repos and some release APIs may fail."
|
||
return 2
|
||
fi
|
||
return 0
|
||
;;
|
||
401)
|
||
msg_error "GitHub token is invalid or expired (HTTP 401)."
|
||
return 1
|
||
;;
|
||
*)
|
||
msg_warn "GitHub token validation returned HTTP $http_code - treating as valid."
|
||
return 0
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Prompt user to enter a GitHub Personal Access Token (PAT) interactively
|
||
# Returns 0 if a valid token was provided, 1 otherwise
|
||
# ------------------------------------------------------------------------------
|
||
prompt_for_github_token() {
|
||
if [[ ! -t 0 ]]; then
|
||
# Non-interactive: pick up var_github_token if set (from default.vars / app.vars / env)
|
||
if [[ -z "${GITHUB_TOKEN:-}" && -n "${var_github_token:-}" ]]; then
|
||
export GITHUB_TOKEN="${var_github_token}"
|
||
msg_ok "GitHub token loaded from var_github_token."
|
||
return 0
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
# Prefer var_github_token when already set and no interactive override needed
|
||
if [[ -z "${GITHUB_TOKEN:-}" && -n "${var_github_token:-}" ]]; then
|
||
export GITHUB_TOKEN="${var_github_token}"
|
||
msg_ok "GitHub token loaded from var_github_token."
|
||
validate_github_token || true
|
||
return 0
|
||
fi
|
||
|
||
local reply
|
||
read -rp "${TAB}Would you like to enter a GitHub Personal Access Token (PAT)? [y/N]: " reply
|
||
reply="${reply:-n}"
|
||
|
||
if [[ ! "${reply,,}" =~ ^(y|yes)$ ]]; then
|
||
return 1
|
||
fi
|
||
|
||
local token
|
||
while true; do
|
||
read -rp "${TAB}Enter your GitHub PAT: " token
|
||
# Trim leading/trailing whitespace
|
||
token="$(echo "$token" | xargs)"
|
||
if [[ -z "$token" ]]; then
|
||
msg_warn "Token cannot be empty. Please try again."
|
||
continue
|
||
fi
|
||
if [[ "$token" =~ [[:space:]] ]]; then
|
||
msg_warn "Token must not contain spaces. Please try again."
|
||
continue
|
||
fi
|
||
# Validate before accepting
|
||
export GITHUB_TOKEN="$token"
|
||
if validate_github_token "$token"; then
|
||
break
|
||
else
|
||
msg_warn "Please enter a valid token, or press Ctrl+C to abort."
|
||
unset GITHUB_TOKEN
|
||
fi
|
||
done
|
||
|
||
msg_ok "GitHub token has been set."
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# GitHub API call with authentication and rate limit handling
|
||
# ------------------------------------------------------------------------------
|
||
github_api_call() {
|
||
local url="$1"
|
||
local output_file="${2:-/dev/stdout}"
|
||
local max_retries=3
|
||
local retry_delay=2
|
||
|
||
local header_args=()
|
||
[[ -n "${GITHUB_TOKEN:-}" ]] && header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
|
||
|
||
local attempt=1
|
||
while ((attempt <= max_retries)); do
|
||
local http_code
|
||
http_code=$(curl -sSL -w "%{http_code}" -o "$output_file" \
|
||
-H "Accept: application/vnd.github+json" \
|
||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||
"${header_args[@]}" \
|
||
"$url" 2>/dev/null) || true
|
||
|
||
case "$http_code" in
|
||
200)
|
||
return 0
|
||
;;
|
||
401)
|
||
msg_error "GitHub API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITHUB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication."
|
||
fi
|
||
if prompt_for_github_token; then
|
||
header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
|
||
continue
|
||
fi
|
||
return 22
|
||
;;
|
||
403)
|
||
# Rate limit - check if we can retry
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
msg_warn "GitHub API rate limit, waiting ${retry_delay}s... (attempt $attempt/$max_retries)"
|
||
sleep "$retry_delay"
|
||
retry_delay=$((retry_delay * 2))
|
||
((attempt++))
|
||
continue
|
||
fi
|
||
msg_error "GitHub API rate limit exceeded (HTTP 403)."
|
||
if prompt_for_github_token; then
|
||
header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
|
||
retry_delay=2
|
||
attempt=1
|
||
continue
|
||
fi
|
||
msg_error "To increase the limit, export a GitHub token before running the script:"
|
||
msg_error " export GITHUB_TOKEN=\"ghp_your_token_here\""
|
||
return 22
|
||
;;
|
||
404)
|
||
msg_error "GitHub repository or release not found (HTTP 404): $url"
|
||
return 22
|
||
;;
|
||
000 | "")
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
sleep "$retry_delay"
|
||
((attempt++))
|
||
continue
|
||
fi
|
||
msg_error "GitHub API connection failed (no response)."
|
||
msg_error "Check your network/DNS: curl -sSL https://api.github.com/rate_limit"
|
||
return 22
|
||
;;
|
||
*)
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
sleep "$retry_delay"
|
||
((attempt++))
|
||
continue
|
||
fi
|
||
msg_error "GitHub API call failed (HTTP $http_code)."
|
||
return 22
|
||
;;
|
||
esac
|
||
((attempt++))
|
||
done
|
||
|
||
msg_error "GitHub API call failed after ${max_retries} attempts: ${url}"
|
||
return 22
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Codeberg API call with retry logic
|
||
# ------------------------------------------------------------------------------
|
||
codeberg_api_call() {
|
||
local url="$1"
|
||
local output_file="${2:-/dev/stdout}"
|
||
local max_retries=3
|
||
local retry_delay=2
|
||
|
||
for attempt in $(seq 1 $max_retries); do
|
||
local http_code
|
||
http_code=$(curl -sSL -w "%{http_code}" -o "$output_file" \
|
||
-H "Accept: application/json" \
|
||
"$url" 2>/dev/null) || true
|
||
|
||
case "$http_code" in
|
||
200)
|
||
return 0
|
||
;;
|
||
401)
|
||
msg_error "Codeberg API authentication failed (HTTP 401)."
|
||
return 22
|
||
;;
|
||
403)
|
||
# Rate limit - retry
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
msg_warn "Codeberg API rate limit, waiting ${retry_delay}s... (attempt $attempt/$max_retries)"
|
||
sleep "$retry_delay"
|
||
retry_delay=$((retry_delay * 2))
|
||
continue
|
||
fi
|
||
msg_error "Codeberg API rate limit exceeded (HTTP 403)."
|
||
return 22
|
||
;;
|
||
404)
|
||
msg_error "Codeberg repository or release not found (HTTP 404): $url"
|
||
return 22
|
||
;;
|
||
000 | "")
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
sleep "$retry_delay"
|
||
continue
|
||
fi
|
||
msg_error "Codeberg API connection failed (no response)."
|
||
msg_error "Check your network/DNS: curl -sSL https://codeberg.org"
|
||
return 22
|
||
;;
|
||
*)
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
sleep "$retry_delay"
|
||
continue
|
||
fi
|
||
msg_error "Codeberg API call failed (HTTP $http_code)."
|
||
return 22
|
||
;;
|
||
esac
|
||
done
|
||
|
||
msg_error "Codeberg API call failed after ${max_retries} attempts: ${url}"
|
||
return 22
|
||
}
|
||
|
||
should_upgrade() {
|
||
local current="$1"
|
||
local target="$2"
|
||
|
||
[[ -z "$current" ]] && return 0
|
||
version_gt "$target" "$current" && return 0
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get OS information (cached for performance)
|
||
# ------------------------------------------------------------------------------
|
||
get_os_info() {
|
||
local field="${1:-all}" # id, codename, version, version_id, all
|
||
|
||
# Cache OS info to avoid repeated file reads
|
||
if [[ -z "${_OS_ID:-}" ]]; then
|
||
export _OS_ID=$(awk -F= '/^ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
|
||
export _OS_CODENAME=$(awk -F= '/^VERSION_CODENAME=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
|
||
export _OS_VERSION=$(awk -F= '/^VERSION_ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
|
||
export _OS_VERSION_FULL=$(awk -F= '/^VERSION=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
|
||
fi
|
||
|
||
case "$field" in
|
||
id) echo "$_OS_ID" ;;
|
||
codename) echo "$_OS_CODENAME" ;;
|
||
version) echo "$_OS_VERSION" ;;
|
||
version_id) echo "$_OS_VERSION" ;;
|
||
version_full) echo "$_OS_VERSION_FULL" ;;
|
||
all) echo "ID=$_OS_ID CODENAME=$_OS_CODENAME VERSION=$_OS_VERSION" ;;
|
||
*) echo "$_OS_ID" ;;
|
||
esac
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if running on specific OS
|
||
# ------------------------------------------------------------------------------
|
||
is_debian() {
|
||
[[ "$(get_os_info id)" == "debian" ]]
|
||
}
|
||
|
||
is_ubuntu() {
|
||
[[ "$(get_os_info id)" == "ubuntu" ]]
|
||
}
|
||
|
||
is_alpine() {
|
||
[[ "$(get_os_info id)" == "alpine" ]]
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get Debian/Ubuntu major version
|
||
# ------------------------------------------------------------------------------
|
||
get_os_version_major() {
|
||
local version=$(get_os_info version)
|
||
echo "${version%%.*}"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Download file with retry logic and progress
|
||
# ------------------------------------------------------------------------------
|
||
download_file() {
|
||
local url="$1"
|
||
local output="$2"
|
||
local max_retries="${3:-3}"
|
||
local show_progress="${4:-false}"
|
||
|
||
local curl_opts=(-fsSL)
|
||
[[ "$show_progress" == "true" ]] && curl_opts=(-fL#)
|
||
|
||
for attempt in $(seq 1 $max_retries); do
|
||
if curl "${curl_opts[@]}" -o "$output" "$url"; then
|
||
return 0
|
||
fi
|
||
|
||
if [[ $attempt -lt $max_retries ]]; then
|
||
msg_warn "Download failed, retrying... (attempt $attempt/$max_retries)"
|
||
sleep 2
|
||
fi
|
||
done
|
||
|
||
msg_error "Failed to download: $url"
|
||
msg_error "Hint: Check network connectivity or DNS resolution. The server may be unreachable or the URL may have changed."
|
||
return 250
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get fallback suite for repository (comprehensive mapping)
|
||
# ------------------------------------------------------------------------------
|
||
get_fallback_suite() {
|
||
local distro_id="$1"
|
||
local distro_codename="$2"
|
||
local repo_base_url="$3"
|
||
|
||
# Check if current codename works
|
||
if verify_repo_available "$repo_base_url" "$distro_codename"; then
|
||
echo "$distro_codename"
|
||
return 0
|
||
fi
|
||
|
||
# Build fallback chain based on distro
|
||
local fallback_chain=()
|
||
case "$distro_id" in
|
||
debian)
|
||
case "$distro_codename" in
|
||
trixie | forky | sid)
|
||
fallback_chain=("bookworm" "bullseye")
|
||
;;
|
||
bookworm)
|
||
fallback_chain=("bookworm" "bullseye")
|
||
;;
|
||
bullseye)
|
||
fallback_chain=("bullseye" "buster")
|
||
;;
|
||
*)
|
||
fallback_chain=("bookworm" "bullseye")
|
||
;;
|
||
esac
|
||
;;
|
||
ubuntu)
|
||
case "$distro_codename" in
|
||
oracular | plucky)
|
||
fallback_chain=("noble" "jammy" "focal")
|
||
;;
|
||
noble)
|
||
fallback_chain=("noble" "jammy")
|
||
;;
|
||
mantic | lunar)
|
||
fallback_chain=("jammy" "focal")
|
||
;;
|
||
jammy)
|
||
fallback_chain=("jammy" "focal")
|
||
;;
|
||
focal)
|
||
fallback_chain=("focal" "bionic")
|
||
;;
|
||
*)
|
||
fallback_chain=("jammy" "focal")
|
||
;;
|
||
esac
|
||
;;
|
||
*)
|
||
echo "$distro_codename"
|
||
return 0
|
||
;;
|
||
esac
|
||
|
||
# Try each fallback suite with actual HTTP check
|
||
for suite in "${fallback_chain[@]}"; do
|
||
if verify_repo_available "$repo_base_url" "$suite"; then
|
||
debug_log "Fallback suite found: $suite for $distro_codename"
|
||
echo "$suite"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
# Last resort: return first fallback without verification
|
||
echo "${fallback_chain[0]:-$distro_codename}"
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Verify package source and version
|
||
# ------------------------------------------------------------------------------
|
||
verify_package_source() {
|
||
local package="$1"
|
||
local expected_version="$2"
|
||
|
||
if apt-cache policy "$package" 2>/dev/null | grep -q "$expected_version"; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if running on LTS version
|
||
# ------------------------------------------------------------------------------
|
||
is_lts_version() {
|
||
local os_id=$(get_os_info id)
|
||
local codename=$(get_os_info codename)
|
||
|
||
if [[ "$os_id" == "ubuntu" ]]; then
|
||
case "$codename" in
|
||
focal | jammy | noble) return 0 ;; # 20.04, 22.04, 24.04
|
||
*) return 1 ;;
|
||
esac
|
||
elif [[ "$os_id" == "debian" ]]; then
|
||
# Debian releases are all "stable"
|
||
case "$codename" in
|
||
bullseye | bookworm | trixie) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get optimal number of parallel jobs (cached)
|
||
# Features:
|
||
# - CPU count detection
|
||
# - Memory-based limiting (1.5GB per job for safety)
|
||
# - Current load awareness
|
||
# - Container/VM detection for conservative limits
|
||
# ------------------------------------------------------------------------------
|
||
get_parallel_jobs() {
|
||
if [[ -z "${_PARALLEL_JOBS:-}" ]]; then
|
||
local cpu_count
|
||
cpu_count=$(nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo 1)
|
||
|
||
local mem_mb
|
||
mem_mb=$(free -m 2>/dev/null | awk '/^Mem:/{print $2}' || echo 1024)
|
||
|
||
# Assume 1.5GB per compilation job for safety margin
|
||
local max_by_mem=$((mem_mb / 1536))
|
||
((max_by_mem < 1)) && max_by_mem=1
|
||
|
||
# Check current system load - reduce jobs if already loaded
|
||
local load_1m
|
||
load_1m=$(awk '{print int($1)}' /proc/loadavg 2>/dev/null || echo 0)
|
||
local available_cpus=$((cpu_count - load_1m))
|
||
((available_cpus < 1)) && available_cpus=1
|
||
|
||
# Take minimum of: available CPUs, memory-limited, and total CPUs
|
||
local max_jobs=$cpu_count
|
||
((max_by_mem < max_jobs)) && max_jobs=$max_by_mem
|
||
((available_cpus < max_jobs)) && max_jobs=$available_cpus
|
||
|
||
# Container detection - be more conservative in containers
|
||
if [[ -f /.dockerenv ]] || grep -q 'lxc\|docker\|container' /proc/1/cgroup 2>/dev/null; then
|
||
# Reduce by 25% in containers to leave headroom
|
||
max_jobs=$((max_jobs * 3 / 4))
|
||
((max_jobs < 1)) && max_jobs=1
|
||
fi
|
||
|
||
# Final bounds check
|
||
((max_jobs < 1)) && max_jobs=1
|
||
((max_jobs > cpu_count)) && max_jobs=$cpu_count
|
||
|
||
export _PARALLEL_JOBS=$max_jobs
|
||
debug_log "Parallel jobs: $_PARALLEL_JOBS (CPUs: $cpu_count, mem-limit: $max_by_mem, load: $load_1m)"
|
||
fi
|
||
echo "$_PARALLEL_JOBS"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get default PHP version for OS
|
||
# Updated for latest distro releases
|
||
# ------------------------------------------------------------------------------
|
||
get_default_php_version() {
|
||
local os_id
|
||
os_id=$(get_os_info id)
|
||
local os_version
|
||
os_version=$(get_os_version_major)
|
||
|
||
case "$os_id" in
|
||
debian)
|
||
case "$os_version" in
|
||
14) echo "8.4" ;; # Debian 14 (Forky) - future
|
||
13) echo "8.3" ;; # Debian 13 (Trixie)
|
||
12) echo "8.2" ;; # Debian 12 (Bookworm)
|
||
11) echo "7.4" ;; # Debian 11 (Bullseye)
|
||
*) echo "8.3" ;; # Default to latest stable
|
||
esac
|
||
;;
|
||
ubuntu)
|
||
case "$os_version" in
|
||
26) echo "8.4" ;; # Ubuntu 26.04 - future
|
||
24) echo "8.3" ;; # Ubuntu 24.04 LTS (Noble)
|
||
22) echo "8.1" ;; # Ubuntu 22.04 LTS (Jammy)
|
||
20) echo "7.4" ;; # Ubuntu 20.04 LTS (Focal)
|
||
*) echo "8.3" ;; # Default to latest stable
|
||
esac
|
||
;;
|
||
*)
|
||
echo "8.3"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get default Python version for OS
|
||
# Updated for latest distro releases
|
||
# ------------------------------------------------------------------------------
|
||
get_default_python_version() {
|
||
local os_id
|
||
os_id=$(get_os_info id)
|
||
local os_version
|
||
os_version=$(get_os_version_major)
|
||
|
||
case "$os_id" in
|
||
debian)
|
||
case "$os_version" in
|
||
14) echo "3.13" ;; # Debian 14 (Forky) - future
|
||
13) echo "3.12" ;; # Debian 13 (Trixie)
|
||
12) echo "3.11" ;; # Debian 12 (Bookworm)
|
||
11) echo "3.9" ;; # Debian 11 (Bullseye)
|
||
*) echo "3.12" ;; # Default to latest stable
|
||
esac
|
||
;;
|
||
ubuntu)
|
||
case "$os_version" in
|
||
26) echo "3.13" ;; # Ubuntu 26.04 - future
|
||
24) echo "3.12" ;; # Ubuntu 24.04 LTS
|
||
22) echo "3.10" ;; # Ubuntu 22.04 LTS
|
||
20) echo "3.8" ;; # Ubuntu 20.04 LTS
|
||
*) echo "3.12" ;; # Default to latest stable
|
||
esac
|
||
;;
|
||
*)
|
||
echo "3.12"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get default Node.js LTS version
|
||
# ------------------------------------------------------------------------------
|
||
get_default_nodejs_version() {
|
||
# Current LTS as of January 2026 (Node.js 24 LTS)
|
||
echo "24"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if package manager is locked
|
||
# ------------------------------------------------------------------------------
|
||
is_apt_locked() {
|
||
if fuser /var/lib/dpkg/lock-frontend &>/dev/null ||
|
||
fuser /var/lib/apt/lists/lock &>/dev/null ||
|
||
fuser /var/cache/apt/archives/lock &>/dev/null; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Wait for apt to be available
|
||
# ------------------------------------------------------------------------------
|
||
wait_for_apt() {
|
||
local max_wait="${1:-300}" # 5 minutes default
|
||
local waited=0
|
||
|
||
while is_apt_locked; do
|
||
if [[ $waited -ge $max_wait ]]; then
|
||
msg_error "Timeout waiting for apt to be available"
|
||
msg_error "Hint: Another process (apt, dpkg, unattended-upgrades) may hold a lock. Check: ps aux | grep -E 'apt|dpkg'"
|
||
return 100
|
||
fi
|
||
|
||
sleep 5
|
||
waited=$((waited + 5))
|
||
done
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Cleanup old repository files (migration helper)
|
||
# ------------------------------------------------------------------------------
|
||
cleanup_old_repo_files() {
|
||
local app="$1"
|
||
|
||
# Remove old-style .list files (including backups)
|
||
rm -f /etc/apt/sources.list.d/"${app}"*.list
|
||
rm -f /etc/apt/sources.list.d/"${app}"*.list.save
|
||
rm -f /etc/apt/sources.list.d/"${app}"*.list.distUpgrade
|
||
rm -f /etc/apt/sources.list.d/"${app}"*.list.dpkg-*
|
||
|
||
# Remove old GPG keys from trusted.gpg.d
|
||
rm -f /etc/apt/trusted.gpg.d/"${app}"*.gpg
|
||
|
||
# Remove keyrings from /etc/apt/keyrings
|
||
rm -f /etc/apt/keyrings/"${app}"*.gpg
|
||
|
||
# Remove ALL .sources files for this app (including the main one)
|
||
# This ensures no orphaned .sources files reference deleted keyrings
|
||
rm -f /etc/apt/sources.list.d/"${app}"*.sources
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Cleanup orphaned .sources files that reference missing keyrings
|
||
# This prevents APT signature verification errors
|
||
# Call this at the start of any setup function to ensure APT is in a clean state
|
||
# ------------------------------------------------------------------------------
|
||
cleanup_orphaned_sources() {
|
||
local sources_dir="/etc/apt/sources.list.d"
|
||
local keyrings_dir="/etc/apt/keyrings"
|
||
|
||
[[ ! -d "$sources_dir" ]] && return 0
|
||
|
||
while IFS= read -r -d '' sources_file; do
|
||
local basename_file
|
||
basename_file=$(basename "$sources_file")
|
||
|
||
# NEVER remove debian.sources - this is the standard Debian repository
|
||
if [[ "$basename_file" == "debian.sources" ]]; then
|
||
continue
|
||
fi
|
||
|
||
# Extract Signed-By path from .sources file
|
||
local keyring_path
|
||
keyring_path=$(grep -E '^Signed-By:' "$sources_file" 2>/dev/null | awk '{print $2}' 2>/dev/null || true)
|
||
|
||
# If keyring doesn't exist, remove the .sources file
|
||
if [[ -n "$keyring_path" ]] && [[ ! -f "$keyring_path" ]]; then
|
||
rm -f "$sources_file"
|
||
fi
|
||
done < <(find "$sources_dir" -name "*.sources" -print0 2>/dev/null)
|
||
|
||
# Also check for broken symlinks in keyrings directory
|
||
if [[ -d "$keyrings_dir" ]]; then
|
||
find "$keyrings_dir" -type l ! -exec test -e {} \; -delete 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Ensure APT is in a working state before installing packages
|
||
# This should be called at the start of any setup function
|
||
# Features:
|
||
# - Fixes interrupted dpkg operations
|
||
# - Removes orphaned sources
|
||
# - Handles lock file contention
|
||
# - Progressive recovery with fallbacks
|
||
# ------------------------------------------------------------------------------
|
||
ensure_apt_working() {
|
||
local max_wait=60 # Maximum seconds to wait for apt lock
|
||
|
||
# Wait for any existing apt/dpkg processes to finish
|
||
local waited=0
|
||
while fuser /var/lib/dpkg/lock-frontend &>/dev/null ||
|
||
fuser /var/lib/apt/lists/lock &>/dev/null ||
|
||
fuser /var/cache/apt/archives/lock &>/dev/null; do
|
||
if ((waited >= max_wait)); then
|
||
msg_warn "APT lock held for ${max_wait}s, attempting to continue anyway"
|
||
break
|
||
fi
|
||
debug_log "Waiting for APT lock (${waited}s)..."
|
||
sleep 2
|
||
((waited += 2))
|
||
done
|
||
|
||
# Fix interrupted dpkg operations first
|
||
# This can happen if a previous installation was interrupted (e.g., by script error)
|
||
if dpkg --audit 2>&1 | grep -q .; then
|
||
debug_log "Fixing interrupted dpkg operations"
|
||
$STD dpkg --configure -a 2>/dev/null || true
|
||
fi
|
||
|
||
# Clean up orphaned sources first
|
||
cleanup_orphaned_sources
|
||
|
||
# Try to update package lists
|
||
if ! $STD apt update 2>/dev/null; then
|
||
debug_log "First apt update failed, trying recovery steps"
|
||
|
||
# Step 1: Clear apt lists cache
|
||
rm -rf /var/lib/apt/lists/* 2>/dev/null || true
|
||
mkdir -p /var/lib/apt/lists/partial
|
||
|
||
# Step 2: Clean up potentially broken sources
|
||
cleanup_orphaned_sources
|
||
|
||
# Step 3: Try again
|
||
if ! $STD apt update 2>/dev/null; then
|
||
# Step 4: More aggressive - remove all third-party sources
|
||
msg_warn "APT update still failing, removing third-party sources"
|
||
find /etc/apt/sources.list.d/ -type f \( -name "*.sources" -o -name "*.list" \) \
|
||
! -name "debian.sources" -delete 2>/dev/null || true
|
||
|
||
# Final attempt
|
||
if ! $STD apt update; then
|
||
msg_error "Cannot update package lists - APT is critically broken"
|
||
return 100
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Standardized deb822 repository setup (with optional Architectures)
|
||
# Always runs apt update after repo creation to ensure package availability
|
||
# ------------------------------------------------------------------------------
|
||
setup_deb822_repo() {
|
||
local name="$1"
|
||
local gpg_url="$2"
|
||
local repo_url="$3"
|
||
local suite="$4"
|
||
local component="${5:-main}"
|
||
local architectures="${6-}" # optional
|
||
local enabled="${7-}" # optional: "true" or "false"
|
||
|
||
# Validate required parameters
|
||
if [[ -z "$name" || -z "$gpg_url" || -z "$repo_url" || -z "$suite" ]]; then
|
||
msg_error "setup_deb822_repo: missing required parameters (name=$name repo=$repo_url suite=$suite)"
|
||
return 65
|
||
fi
|
||
|
||
# Cleanup
|
||
cleanup_old_repo_files "$name"
|
||
cleanup_orphaned_sources
|
||
|
||
mkdir -p /etc/apt/keyrings || {
|
||
msg_error "Failed to create /etc/apt/keyrings"
|
||
return 252
|
||
}
|
||
|
||
# Import GPG key (auto-detect binary vs ASCII-armored format)
|
||
local tmp_gpg
|
||
tmp_gpg=$(mktemp) || return 252
|
||
curl -fsSL "$gpg_url" -o "$tmp_gpg" || {
|
||
msg_error "Failed to download GPG key for ${name} from: ${gpg_url}"
|
||
msg_error "Hint: Check network connectivity. If behind a proxy or firewall, ensure HTTPS access to $(echo "$gpg_url" | grep -oE 'https?://[^/]+') is allowed."
|
||
rm -f "$tmp_gpg"
|
||
return 7
|
||
}
|
||
|
||
if grep -q "BEGIN PGP" "$tmp_gpg" 2>/dev/null; then
|
||
# ASCII-armored — dearmor to binary
|
||
gpg --dearmor --yes -o "/etc/apt/keyrings/${name}.gpg" <"$tmp_gpg" || {
|
||
msg_error "Failed to install GPG key for ${name}"
|
||
rm -f "$tmp_gpg"
|
||
return 251
|
||
}
|
||
else
|
||
# Already binary — copy directly
|
||
cp -f "$tmp_gpg" "/etc/apt/keyrings/${name}.gpg" || {
|
||
msg_error "Failed to install GPG key for ${name}"
|
||
rm -f "$tmp_gpg"
|
||
return 252
|
||
}
|
||
fi
|
||
rm -f "$tmp_gpg"
|
||
chmod 644 "/etc/apt/keyrings/${name}.gpg"
|
||
|
||
# Write deb822
|
||
{
|
||
echo "Types: deb"
|
||
echo "URIs: $repo_url"
|
||
echo "Suites: $suite"
|
||
# Flat repositories (suite ending with "/" or "./") must not have Components
|
||
if [[ "$suite" != *"/" && -n "$component" ]]; then
|
||
echo "Components: $component"
|
||
fi
|
||
[[ -n "$architectures" ]] && echo "Architectures: $architectures"
|
||
echo "Signed-By: /etc/apt/keyrings/${name}.gpg"
|
||
[[ -n "$enabled" ]] && echo "Enabled: $enabled"
|
||
} >/etc/apt/sources.list.d/${name}.sources
|
||
|
||
$STD apt update || {
|
||
msg_warn "apt update failed after adding repository: ${name}"
|
||
}
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Package version hold/unhold helpers
|
||
# ------------------------------------------------------------------------------
|
||
hold_package_version() {
|
||
local package="$1"
|
||
$STD apt-mark hold "$package" || {
|
||
msg_warn "Failed to hold package version: ${package}"
|
||
}
|
||
}
|
||
|
||
unhold_package_version() {
|
||
local package="$1"
|
||
$STD apt-mark unhold "$package" || {
|
||
msg_warn "Failed to unhold package version: ${package}"
|
||
}
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Safe service restart with verification
|
||
# ------------------------------------------------------------------------------
|
||
# ------------------------------------------------------------------------------
|
||
# Safe service restart with retry logic and wait-for-ready
|
||
# Usage: safe_service_restart "nginx" [timeout_seconds]
|
||
# ------------------------------------------------------------------------------
|
||
safe_service_restart() {
|
||
local service="$1"
|
||
local timeout="${2:-30}" # Default 30 second timeout
|
||
local max_retries=2
|
||
local retry=0
|
||
|
||
while [[ $retry -le $max_retries ]]; do
|
||
if systemctl is-active --quiet "$service"; then
|
||
$STD systemctl restart "$service"
|
||
else
|
||
$STD systemctl start "$service"
|
||
fi
|
||
|
||
# Wait for service to become active with timeout
|
||
local waited=0
|
||
while [[ $waited -lt $timeout ]]; do
|
||
if systemctl is-active --quiet "$service"; then
|
||
return 0
|
||
fi
|
||
sleep 1
|
||
((waited++))
|
||
done
|
||
|
||
retry=$((retry + 1))
|
||
if [[ $retry -le $max_retries ]]; then
|
||
debug_log "Service $service failed to start, retrying ($retry/$max_retries)..."
|
||
# Try to stop completely before retry
|
||
systemctl stop "$service" 2>/dev/null || true
|
||
sleep 2
|
||
fi
|
||
done
|
||
|
||
msg_error "Failed to start $service after $max_retries retries"
|
||
systemctl status "$service" --no-pager -l 2>/dev/null | head -20 || true
|
||
return 150
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Enable and start service (with error handling)
|
||
# ------------------------------------------------------------------------------
|
||
enable_and_start_service() {
|
||
local service="$1"
|
||
|
||
if ! systemctl enable "$service" &>/dev/null; then
|
||
msg_error "Failed to enable service: $service"
|
||
return 150
|
||
fi
|
||
|
||
if ! systemctl start "$service" &>/dev/null; then
|
||
msg_error "Failed to start $service"
|
||
systemctl status "$service" --no-pager
|
||
return 150
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if service is enabled
|
||
# ------------------------------------------------------------------------------
|
||
is_service_enabled() {
|
||
local service="$1"
|
||
systemctl is-enabled --quiet "$service" 2>/dev/null
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Check if service is running
|
||
# ------------------------------------------------------------------------------
|
||
is_service_running() {
|
||
local service="$1"
|
||
systemctl is-active --quiet "$service" 2>/dev/null
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Extract version from JSON (GitHub releases)
|
||
# ------------------------------------------------------------------------------
|
||
extract_version_from_json() {
|
||
local json="$1"
|
||
local field="${2:-tag_name}"
|
||
local strip_v="${3:-true}"
|
||
|
||
ensure_dependencies jq
|
||
|
||
local version
|
||
version=$(echo "$json" | jq -r ".${field} // empty")
|
||
|
||
if [[ -z "$version" ]]; then
|
||
msg_warn "JSON field '${field}' is empty in API response"
|
||
return 250
|
||
fi
|
||
|
||
if [[ "$strip_v" == "true" ]]; then
|
||
echo "${version#v}"
|
||
else
|
||
echo "$version"
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get latest GitHub tag (for repos that only publish tags, not releases).
|
||
#
|
||
# Usage:
|
||
# get_latest_gh_tag "owner/repo" [prefix]
|
||
#
|
||
# Arguments:
|
||
# $1 - GitHub repo (owner/repo)
|
||
# $2 - Optional prefix filter (e.g., "v" to only match tags starting with "v")
|
||
#
|
||
# Returns:
|
||
# Latest tag name (stdout), or returns 1 on failure
|
||
# ------------------------------------------------------------------------------
|
||
get_latest_gh_tag() {
|
||
local repo="$1"
|
||
local prefix="${2:-}"
|
||
local temp_file
|
||
temp_file=$(mktemp)
|
||
|
||
local tag=""
|
||
|
||
if [[ -n "$prefix" ]]; then
|
||
# Use git/matching-refs API for server-side prefix filtering. This avoids
|
||
# paging through unrelated tags (e.g. mongodb/mongo-tools where 100.x tags
|
||
# only appear after page 4 of /tags). Returns ALL tags matching the prefix
|
||
# in a single call, sorted lexicographically ascending; we pick the
|
||
# highest version using `sort -V`.
|
||
if ! github_api_call "https://api.github.com/repos/${repo}/git/matching-refs/tags/${prefix}" "$temp_file"; then
|
||
rm -f "$temp_file"
|
||
return 22
|
||
fi
|
||
|
||
local count
|
||
count=$(jq 'length' "$temp_file" 2>/dev/null || echo 0)
|
||
if [[ "$count" -gt 0 ]]; then
|
||
tag=$(jq -r '.[].ref' "$temp_file" |
|
||
sed 's|^refs/tags/||' |
|
||
sort -V |
|
||
tail -n1)
|
||
fi
|
||
else
|
||
# No prefix: just take the first (newest) tag from /tags
|
||
if ! github_api_call "https://api.github.com/repos/${repo}/tags?per_page=1" "$temp_file"; then
|
||
rm -f "$temp_file"
|
||
return 22
|
||
fi
|
||
tag=$(jq -r '.[0].name // empty' "$temp_file")
|
||
fi
|
||
|
||
rm -f "$temp_file"
|
||
|
||
if [[ -z "$tag" ]]; then
|
||
msg_error "No tags found for ${repo}"
|
||
return 250
|
||
fi
|
||
|
||
echo "$tag"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get latest GitHub release version with fallback to tags
|
||
# Usage: get_latest_github_release "owner/repo" [strip_v] [include_prerelease]
|
||
# ------------------------------------------------------------------------------
|
||
get_latest_github_release() {
|
||
local repo="$1"
|
||
local strip_v="${2:-true}"
|
||
local temp_file=$(mktemp)
|
||
|
||
if ! github_api_call "https://api.github.com/repos/${repo}/releases/latest" "$temp_file"; then
|
||
msg_warn "GitHub API call failed for ${repo}"
|
||
rm -f "$temp_file"
|
||
return 22
|
||
fi
|
||
|
||
local version
|
||
version=$(extract_version_from_json "$(cat "$temp_file")" "tag_name" "$strip_v")
|
||
rm -f "$temp_file"
|
||
|
||
if [[ -z "$version" ]]; then
|
||
msg_error "Could not determine latest version for ${repo}"
|
||
return 250
|
||
fi
|
||
|
||
echo "$version"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get latest Codeberg release version
|
||
# ------------------------------------------------------------------------------
|
||
get_latest_codeberg_release() {
|
||
local repo="$1"
|
||
local strip_v="${2:-true}"
|
||
local temp_file=$(mktemp)
|
||
|
||
# Codeberg API: get all releases and pick the first non-draft/non-prerelease
|
||
if ! codeberg_api_call "https://codeberg.org/api/v1/repos/${repo}/releases" "$temp_file"; then
|
||
msg_warn "Codeberg API call failed for ${repo}"
|
||
rm -f "$temp_file"
|
||
return 22
|
||
fi
|
||
|
||
local version
|
||
# Codeberg uses same JSON structure but releases endpoint returns array
|
||
version=$(jq -r '[.[] | select(.draft==false and .prerelease==false)][0].tag_name // empty' "$temp_file")
|
||
|
||
if [[ "$strip_v" == "true" ]]; then
|
||
version="${version#v}"
|
||
fi
|
||
|
||
rm -f "$temp_file"
|
||
|
||
if [[ -z "$version" ]]; then
|
||
msg_error "Could not determine latest version for ${repo}"
|
||
return 250
|
||
fi
|
||
|
||
echo "$version"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Debug logging - using main debug_log function (line 40)
|
||
# Supports both TOOLS_DEBUG and DEBUG environment variables
|
||
# ------------------------------------------------------------------------------
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Performance timing helper
|
||
# ------------------------------------------------------------------------------
|
||
start_timer() {
|
||
echo $(date +%s)
|
||
}
|
||
|
||
end_timer() {
|
||
local start_time="$1"
|
||
local label="${2:-Operation}"
|
||
local end_time=$(date +%s)
|
||
local duration=$((end_time - start_time))
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# GPG key fingerprint verification
|
||
# ------------------------------------------------------------------------------
|
||
verify_gpg_fingerprint() {
|
||
local key_file="$1"
|
||
local expected_fingerprint="$2"
|
||
|
||
local actual_fingerprint
|
||
actual_fingerprint=$(gpg --show-keys --with-fingerprint --with-colons "$key_file" 2>&1 | grep -m1 '^fpr:' | cut -d: -f10)
|
||
|
||
if [[ "$actual_fingerprint" == "$expected_fingerprint" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
msg_error "GPG fingerprint mismatch! Expected: $expected_fingerprint, Got: $actual_fingerprint"
|
||
return 65
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Fetches and deploys a GitHub tag-based source tarball.
|
||
#
|
||
# Description:
|
||
# - Downloads the source tarball for a given tag from GitHub
|
||
# - Extracts to the target directory
|
||
# - Writes the version to ~/.<app>
|
||
#
|
||
# Usage:
|
||
# fetch_and_deploy_gh_tag "guacd" "apache/guacamole-server"
|
||
# fetch_and_deploy_gh_tag "guacd" "apache/guacamole-server" "latest" "/opt/guacamole-server"
|
||
#
|
||
# Arguments:
|
||
# $1 - App name (used for version file ~/.<app>)
|
||
# $2 - GitHub repo (owner/repo)
|
||
# $3 - Tag version (default: "latest" → auto-detect via get_latest_gh_tag)
|
||
# $4 - Target directory (default: /opt/$app)
|
||
#
|
||
# Notes:
|
||
# - Supports CLEAN_INSTALL=1 to wipe target before extracting
|
||
# - For repos that only publish tags, not GitHub Releases
|
||
# ------------------------------------------------------------------------------
|
||
fetch_and_deploy_gh_tag() {
|
||
local app="$1"
|
||
local repo="$2"
|
||
local version="${3:-latest}"
|
||
local target="${4:-/opt/$app}"
|
||
local app_lc=""
|
||
app_lc="$(echo "${app,,}" | tr -d ' ')"
|
||
local version_file="$HOME/.${app_lc}"
|
||
|
||
if [[ "$version" == "latest" ]]; then
|
||
version=$(get_latest_gh_tag "$repo") || {
|
||
msg_error "Failed to determine latest tag for ${repo}"
|
||
return 250
|
||
}
|
||
fi
|
||
|
||
local current_version=""
|
||
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
|
||
|
||
if [[ "$current_version" == "$version" ]]; then
|
||
msg_ok "$app is already up-to-date ($version)"
|
||
return 0
|
||
fi
|
||
|
||
local tmpdir
|
||
tmpdir=$(mktemp -d) || return 1
|
||
local tarball_url="https://github.com/${repo}/archive/refs/tags/${version}.tar.gz"
|
||
local filename="${app_lc}-${version}.tar.gz"
|
||
|
||
msg_info "Fetching GitHub tag: ${app} (${version})"
|
||
|
||
download_file "$tarball_url" "$tmpdir/$filename" || {
|
||
msg_error "Download failed: $tarball_url"
|
||
rm -rf "$tmpdir"
|
||
return 7
|
||
}
|
||
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
tar --no-same-owner -xzf "$tmpdir/$filename" -C "$tmpdir" || {
|
||
msg_error "Failed to extract tarball"
|
||
rm -rf "$tmpdir"
|
||
return 251
|
||
}
|
||
|
||
local unpack_dir
|
||
unpack_dir=$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d | head -n1)
|
||
|
||
shopt -s dotglob nullglob
|
||
cp -r "$unpack_dir"/* "$target/"
|
||
shopt -u dotglob nullglob
|
||
|
||
rm -rf "$tmpdir"
|
||
echo "$version" >"$version_file"
|
||
msg_ok "Deployed ${app} ${version} to ${target}"
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Checks for new GitHub tag (for repos without releases).
|
||
#
|
||
# Description:
|
||
# - Uses get_latest_gh_tag to fetch the latest tag
|
||
# - Compares it to a local cached version (~/.<app>)
|
||
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
|
||
#
|
||
# Usage:
|
||
# if check_for_gh_tag "guacd" "apache/guacamole-server"; then
|
||
# fetch_and_deploy_gh_tag "guacd" "apache/guacamole-server" "/opt/guacamole-server"
|
||
# fi
|
||
#
|
||
# Notes:
|
||
# - For repos that only publish tags, not GitHub Releases
|
||
# - Same interface as check_for_gh_release
|
||
# ------------------------------------------------------------------------------
|
||
check_for_gh_tag() {
|
||
local app="$1"
|
||
local repo="$2"
|
||
local prefix="${3:-}"
|
||
local app_lc=""
|
||
app_lc="$(echo "${app,,}" | tr -d ' ')"
|
||
local current_file="$HOME/.${app_lc}"
|
||
|
||
msg_info "Checking for update: ${app}"
|
||
|
||
local latest=""
|
||
latest=$(get_latest_gh_tag "$repo" "$prefix") || return 22
|
||
|
||
local current=""
|
||
[[ -f "$current_file" ]] && current="$(<"$current_file")"
|
||
|
||
if [[ -z "$current" || "$current" != "$latest" ]]; then
|
||
CHECK_UPDATE_RELEASE="$latest"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${latest}"
|
||
return 0
|
||
fi
|
||
|
||
msg_ok "No update available: ${app} (${latest})"
|
||
return 1
|
||
}
|
||
|
||
# ==============================================================================
|
||
# INSTALL FUNCTIONS
|
||
# ==============================================================================
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Checks for new GitHub release (latest tag).
|
||
#
|
||
# Description:
|
||
# - Queries the GitHub API for the latest release tag
|
||
# - Compares it to a local cached version (~/.<app>)
|
||
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
|
||
#
|
||
# Usage:
|
||
# if check_for_gh_release "flaresolverr" "FlareSolverr/FlareSolverr" [optional] "v1.1.1"; then
|
||
# # trigger update...
|
||
# fi
|
||
# exit 0
|
||
# } (end of update_script not from the function)
|
||
#
|
||
# Notes:
|
||
# - Requires `jq` (auto-installed if missing)
|
||
# - Does not modify anything, only checks version state
|
||
# - Does not support pre-releases
|
||
# ------------------------------------------------------------------------------
|
||
check_for_gh_release() {
|
||
local app="$1"
|
||
local source="$2"
|
||
local pinned_version_in="${3:-}" # optional
|
||
local pin_reason="${4:-}" # optional reason shown to user
|
||
local app_lc=""
|
||
app_lc="$(echo "${app,,}" | tr -d ' ')"
|
||
local current_file="$HOME/.${app_lc}"
|
||
|
||
msg_info "Checking for update: ${app}"
|
||
|
||
# DNS check
|
||
if ! getent hosts api.github.com >/dev/null 2>&1; then
|
||
msg_error "Network error: cannot resolve api.github.com"
|
||
return 6
|
||
fi
|
||
|
||
ensure_dependencies jq
|
||
|
||
# Build auth header if token is available
|
||
local header_args=()
|
||
[[ -n "${GITHUB_TOKEN:-}" ]] && header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
|
||
|
||
# Try /latest endpoint for non-pinned versions (most efficient)
|
||
local releases_json="" http_code=""
|
||
|
||
# For pinned versions, query the specific release tag directly
|
||
if [[ -n "$pinned_version_in" ]]; then
|
||
local pinned_version_encoded="${pinned_version_in//\//%2F}"
|
||
http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o /tmp/gh_check.json \
|
||
-H 'Accept: application/vnd.github+json' \
|
||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||
"${header_args[@]}" \
|
||
"https://api.github.com/repos/${source}/releases/tags/${pinned_version_encoded}" 2>/dev/null) || true
|
||
|
||
if [[ "$http_code" == "200" ]] && [[ -s /tmp/gh_check.json ]]; then
|
||
releases_json="[$(</tmp/gh_check.json)]"
|
||
elif [[ "$http_code" == "401" ]]; then
|
||
msg_error "GitHub API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITHUB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication. Try: export GITHUB_TOKEN=\"ghp_your_token\""
|
||
fi
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "403" ]]; then
|
||
msg_error "GitHub API rate limit exceeded (HTTP 403)."
|
||
msg_error "To increase the limit, export a GitHub token before running the script:"
|
||
msg_error " export GITHUB_TOKEN=\"ghp_your_token_here\""
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
fi
|
||
rm -f /tmp/gh_check.json
|
||
fi
|
||
|
||
if [[ -z "$pinned_version_in" ]]; then
|
||
http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o /tmp/gh_check.json \
|
||
-H 'Accept: application/vnd.github+json' \
|
||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||
"${header_args[@]}" \
|
||
"https://api.github.com/repos/${source}/releases/latest" 2>/dev/null) || true
|
||
|
||
if [[ "$http_code" == "200" ]] && [[ -s /tmp/gh_check.json ]]; then
|
||
releases_json="[$(</tmp/gh_check.json)]"
|
||
elif [[ "$http_code" == "401" ]]; then
|
||
msg_error "GitHub API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITHUB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication. Try: export GITHUB_TOKEN=\"ghp_your_token\""
|
||
fi
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "403" ]]; then
|
||
msg_error "GitHub API rate limit exceeded (HTTP 403)."
|
||
msg_error "To increase the limit, export a GitHub token before running the script:"
|
||
msg_error " export GITHUB_TOKEN=\"ghp_your_token_here\""
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
fi
|
||
rm -f /tmp/gh_check.json
|
||
fi
|
||
|
||
# If no releases yet (pinned version OR /latest failed), fetch up to 100
|
||
if [[ -z "$releases_json" ]]; then
|
||
http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o /tmp/gh_check.json \
|
||
-H 'Accept: application/vnd.github+json' \
|
||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||
"${header_args[@]}" \
|
||
"https://api.github.com/repos/${source}/releases?per_page=100" 2>/dev/null) || true
|
||
|
||
if [[ "$http_code" == "200" ]] && [[ -s /tmp/gh_check.json ]]; then
|
||
releases_json=$(</tmp/gh_check.json)
|
||
elif [[ "$http_code" == "401" ]]; then
|
||
msg_error "GitHub API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITHUB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication. Try: export GITHUB_TOKEN=\"ghp_your_token\""
|
||
fi
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "403" ]]; then
|
||
msg_error "GitHub API rate limit exceeded (HTTP 403)."
|
||
msg_error "To increase the limit, export a GitHub token before running the script:"
|
||
msg_error " export GITHUB_TOKEN=\"ghp_your_token_here\""
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "000" || -z "$http_code" ]]; then
|
||
msg_error "GitHub API connection failed (no response)."
|
||
msg_error "Check your network/DNS: curl -sSL https://api.github.com/rate_limit"
|
||
rm -f /tmp/gh_check.json
|
||
return 7
|
||
else
|
||
msg_error "Unable to fetch releases for ${app} (HTTP ${http_code})"
|
||
rm -f /tmp/gh_check.json
|
||
return 22
|
||
fi
|
||
rm -f /tmp/gh_check.json
|
||
fi
|
||
|
||
mapfile -t raw_tags < <(jq -r '.[] | select(.draft==false and .prerelease==false) | .tag_name' <<<"$releases_json")
|
||
if ((${#raw_tags[@]} == 0)); then
|
||
msg_error "No stable releases found for ${app}"
|
||
return 250
|
||
fi
|
||
|
||
local clean_tags=()
|
||
for t in "${raw_tags[@]}"; do
|
||
# Only strip leading 'v' when followed by a digit (e.g. v1.2.3)
|
||
if [[ "$t" =~ ^v[0-9] ]]; then
|
||
clean_tags+=("${t:1}")
|
||
else
|
||
clean_tags+=("$t")
|
||
fi
|
||
done
|
||
|
||
local latest_raw="${raw_tags[0]}"
|
||
local latest_clean="${clean_tags[0]}"
|
||
|
||
# current installed (stored without v)
|
||
local current=""
|
||
if [[ -f "$current_file" ]]; then
|
||
current="$(<"$current_file")"
|
||
else
|
||
# Migration: search for any /opt/*_version.txt
|
||
local legacy_files
|
||
mapfile -t legacy_files < <(find /opt -maxdepth 1 -type f -name "*_version.txt" 2>/dev/null)
|
||
if ((${#legacy_files[@]} == 1)); then
|
||
current="$(<"${legacy_files[0]}")"
|
||
echo "${current#v}" >"$current_file"
|
||
rm -f "${legacy_files[0]}"
|
||
fi
|
||
fi
|
||
if [[ "$current" =~ ^v[0-9] ]]; then
|
||
current="${current:1}"
|
||
fi
|
||
|
||
# Pinned version handling
|
||
if [[ -n "$pinned_version_in" ]]; then
|
||
local pin_clean
|
||
if [[ "$pinned_version_in" =~ ^v[0-9] ]]; then
|
||
pin_clean="${pinned_version_in:1}"
|
||
else
|
||
pin_clean="$pinned_version_in"
|
||
fi
|
||
local match_raw=""
|
||
for i in "${!clean_tags[@]}"; do
|
||
if [[ "${clean_tags[$i]}" == "$pin_clean" ]]; then
|
||
match_raw="${raw_tags[$i]}"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -z "$match_raw" ]]; then
|
||
msg_error "Pinned version ${pinned_version_in} not found upstream"
|
||
return 250
|
||
fi
|
||
|
||
if [[ "$current" != "$pin_clean" ]]; then
|
||
CHECK_UPDATE_RELEASE="$match_raw"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${pin_clean}"
|
||
return 0
|
||
fi
|
||
|
||
if [[ -n "$pin_reason" ]]; then
|
||
msg_ok "No update available: ${app} (${current}) - update held back: ${pin_reason}"
|
||
else
|
||
msg_ok "No update available: ${app} (${current}) - update temporarily held back due to issues with newer releases"
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
# No pinning → use latest
|
||
if [[ -z "$current" || "$current" != "$latest_clean" ]]; then
|
||
CHECK_UPDATE_RELEASE="$latest_raw"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${latest_clean}"
|
||
return 0
|
||
fi
|
||
|
||
msg_ok "No update available: ${app} (${latest_clean})"
|
||
return 1
|
||
}
|
||
# ------------------------------------------------------------------------------
|
||
# Checks for new Codeberg release (latest tag).
|
||
#
|
||
# Description:
|
||
# - Queries the Codeberg API for the latest release tag
|
||
# - Compares it to a local cached version (~/.<app>)
|
||
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
|
||
#
|
||
# Usage:
|
||
# if check_for_codeberg_release "autocaliweb" "gelbphoenix/autocaliweb" [optional] "v0.11.3"; then
|
||
# # trigger update...
|
||
# fi
|
||
# exit 0
|
||
# } (end of update_script not from the function)
|
||
#
|
||
# Notes:
|
||
# - Requires `jq` (auto-installed if missing)
|
||
# - Does not modify anything, only checks version state
|
||
# - Does not support pre-releases
|
||
# ------------------------------------------------------------------------------
|
||
check_for_codeberg_release() {
|
||
local app="$1"
|
||
local source="$2"
|
||
local pinned_version_in="${3:-}" # optional
|
||
local pin_reason="${4:-}" # optional reason shown to user
|
||
local app_lc="${app,,}"
|
||
local current_file="$HOME/.${app_lc}"
|
||
|
||
msg_info "Checking for update: ${app}"
|
||
|
||
# DNS check
|
||
if ! getent hosts codeberg.org >/dev/null 2>&1; then
|
||
msg_error "Network error: cannot resolve codeberg.org"
|
||
return 6
|
||
fi
|
||
|
||
ensure_dependencies jq
|
||
|
||
# Fetch releases from Codeberg API
|
||
local releases_json=""
|
||
releases_json=$(curl -fsSL --max-time 20 \
|
||
-H 'Accept: application/json' \
|
||
"https://codeberg.org/api/v1/repos/${source}/releases" 2>/dev/null) || {
|
||
msg_error "Unable to fetch releases for ${app} (codeberg.org/api/v1/repos/${source}/releases)"
|
||
return 22
|
||
}
|
||
|
||
mapfile -t raw_tags < <(jq -r '.[] | select(.draft==false and .prerelease==false) | .tag_name' <<<"$releases_json")
|
||
if ((${#raw_tags[@]} == 0)); then
|
||
msg_error "No stable releases found for ${app}"
|
||
return 250
|
||
fi
|
||
|
||
local clean_tags=()
|
||
for t in "${raw_tags[@]}"; do
|
||
# Only strip leading 'v' when followed by a digit (e.g. v1.2.3)
|
||
if [[ "$t" =~ ^v[0-9] ]]; then
|
||
clean_tags+=("${t:1}")
|
||
else
|
||
clean_tags+=("$t")
|
||
fi
|
||
done
|
||
|
||
local latest_raw="${raw_tags[0]}"
|
||
local latest_clean="${clean_tags[0]}"
|
||
|
||
# current installed (stored without v)
|
||
local current=""
|
||
if [[ -f "$current_file" ]]; then
|
||
current="$(<"$current_file")"
|
||
else
|
||
# Migration: search for any /opt/*_version.txt
|
||
local legacy_files
|
||
mapfile -t legacy_files < <(find /opt -maxdepth 1 -type f -name "*_version.txt" 2>/dev/null)
|
||
if ((${#legacy_files[@]} == 1)); then
|
||
current="$(<"${legacy_files[0]}")"
|
||
echo "${current#v}" >"$current_file"
|
||
rm -f "${legacy_files[0]}"
|
||
fi
|
||
fi
|
||
current="${current#v}"
|
||
|
||
# Pinned version handling
|
||
if [[ -n "$pinned_version_in" ]]; then
|
||
local pin_clean="${pinned_version_in#v}"
|
||
local match_raw=""
|
||
for i in "${!clean_tags[@]}"; do
|
||
if [[ "${clean_tags[$i]}" == "$pin_clean" ]]; then
|
||
match_raw="${raw_tags[$i]}"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -z "$match_raw" ]]; then
|
||
msg_error "Pinned version ${pinned_version_in} not found upstream"
|
||
return 250
|
||
fi
|
||
|
||
if [[ "$current" != "$pin_clean" ]]; then
|
||
CHECK_UPDATE_RELEASE="$match_raw"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${pin_clean}"
|
||
return 0
|
||
fi
|
||
|
||
if [[ -n "$pin_reason" ]]; then
|
||
msg_ok "No update available: ${app} (${current}) - update held back: ${pin_reason}"
|
||
else
|
||
msg_ok "No update available: ${app} (${current}) - update temporarily held back due to issues with newer releases"
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
# No pinning → use latest
|
||
if [[ -z "$current" || "$current" != "$latest_clean" ]]; then
|
||
CHECK_UPDATE_RELEASE="$latest_raw"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${latest_clean}"
|
||
return 0
|
||
fi
|
||
|
||
msg_ok "No update available: ${app} (${latest_clean})"
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Creates and installs self-signed certificates.
|
||
#
|
||
# Description:
|
||
# - Create a self-signed certificate with option to override application name
|
||
#
|
||
# Variables:
|
||
# APP - Application name (default: $APPLICATION variable)
|
||
# ------------------------------------------------------------------------------
|
||
create_self_signed_cert() {
|
||
local APP_NAME="${1:-${APPLICATION}}"
|
||
local HOSTNAME="$(hostname -f)"
|
||
local IP="$(hostname -I | awk '{print $1}')"
|
||
local APP_NAME_LC=$(echo "${APP_NAME,,}" | tr -d ' ')
|
||
local CERT_DIR="/etc/ssl/${APP_NAME_LC}"
|
||
local CERT_KEY="${CERT_DIR}/${APP_NAME_LC}.key"
|
||
local CERT_CRT="${CERT_DIR}/${APP_NAME_LC}.crt"
|
||
|
||
if [[ -f "$CERT_CRT" && -f "$CERT_KEY" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
# Use ensure_dependencies for cleaner handling
|
||
ensure_dependencies openssl || {
|
||
msg_error "Failed to install OpenSSL"
|
||
return 100
|
||
}
|
||
|
||
mkdir -p "$CERT_DIR"
|
||
$STD openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 \
|
||
-subj "/CN=${HOSTNAME}" \
|
||
-addext "subjectAltName=DNS:${HOSTNAME},DNS:localhost,IP:${IP},IP:127.0.0.1" \
|
||
-keyout "$CERT_KEY" \
|
||
-out "$CERT_CRT" || {
|
||
msg_error "Failed to create self-signed certificate"
|
||
return 150
|
||
}
|
||
|
||
chmod 600 "$CERT_KEY"
|
||
chmod 644 "$CERT_CRT"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Downloads file with optional progress indicator using pv.
|
||
#
|
||
# Arguments:
|
||
# $1 - URL
|
||
# $2 - Destination path
|
||
# ------------------------------------------------------------------------------
|
||
|
||
download_with_progress() {
|
||
local url="$1"
|
||
local output="$2"
|
||
if [ -n "$SPINNER_PID" ] && ps -p "$SPINNER_PID" >/dev/null; then kill "$SPINNER_PID" >/dev/null; fi
|
||
|
||
ensure_dependencies pv
|
||
set -o pipefail
|
||
|
||
# Content-Length aus HTTP-Header holen
|
||
local content_length
|
||
content_length=$(curl -fsSLI "$url" | awk '/Content-Length/ {print $2}' | tr -d '\r' || true)
|
||
|
||
if [[ -z "$content_length" ]]; then
|
||
if ! curl -fL# -o "$output" "$url"; then
|
||
msg_error "Download failed: $url"
|
||
return 7
|
||
fi
|
||
else
|
||
if ! curl -fsSL "$url" | pv -s "$content_length" >"$output"; then
|
||
msg_error "Download failed: $url"
|
||
return 7
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Ensures /usr/local/bin is permanently in system PATH.
|
||
#
|
||
# Description:
|
||
# - Adds to /etc/profile.d for login shells (SSH, noVNC)
|
||
# - Adds to /root/.bashrc for non-login shells (pct enter)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
ensure_usr_local_bin_persist() {
|
||
# Skip on Proxmox host
|
||
command -v pveversion &>/dev/null && return
|
||
|
||
# Login shells: /etc/profile.d/
|
||
local PROFILE_FILE="/etc/profile.d/custom_path.sh"
|
||
if [[ ! -f "$PROFILE_FILE" ]]; then
|
||
echo 'export PATH="/usr/local/bin:$PATH"' >"$PROFILE_FILE"
|
||
chmod +x "$PROFILE_FILE"
|
||
fi
|
||
|
||
# Non-login shells (pct enter): /root/.bashrc
|
||
local BASHRC="/root/.bashrc"
|
||
if [[ -f "$BASHRC" ]] && ! grep -q '/usr/local/bin' "$BASHRC"; then
|
||
echo 'export PATH="/usr/local/bin:$PATH"' >>"$BASHRC"
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# curl_download - Downloads a file with stall detection and retry.
|
||
#
|
||
# Usage: curl_download <output_file> <url>
|
||
#
|
||
# Uses --speed-limit / --speed-time instead of a hard --max-time cap so that
|
||
# slow but progressing downloads (e.g. large .deb files from slow mirrors) are
|
||
# never aborted mid-transfer. Only aborts when throughput drops below 1 KB/s
|
||
# for 60 consecutive seconds (i.e. a genuine stall or dead connection).
|
||
# Retries up to 3 times on failure.
|
||
# Returns 0 on success, 7 if all attempts fail.
|
||
# ------------------------------------------------------------------------------
|
||
curl_download() {
|
||
local output="$1"
|
||
local url="$2"
|
||
local retries=3
|
||
local attempt=1
|
||
|
||
while ((attempt <= retries)); do
|
||
if curl --connect-timeout 15 \
|
||
--speed-limit 1024 \
|
||
--speed-time 60 \
|
||
-fsSL -o "$output" "$url"; then
|
||
return 0
|
||
fi
|
||
if ((attempt < retries)); then
|
||
msg_warn "Download failed or stalled (attempt ${attempt}/${retries}), retrying..."
|
||
fi
|
||
((attempt++))
|
||
done
|
||
return 7
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Downloads and deploys latest Codeberg release (source, binary, tarball, asset).
|
||
#
|
||
# Description:
|
||
# - Fetches latest release metadata from Codeberg API
|
||
# - Supports the following modes:
|
||
# - tarball: Source code tarball (default if omitted)
|
||
# - source: Alias for tarball (same behavior)
|
||
# - binary: .deb package install (arch-dependent)
|
||
# - prebuild: Prebuilt .tar.gz archive (e.g. Go binaries)
|
||
# - singlefile: Standalone binary (no archive, direct chmod +x install)
|
||
# - tag: Direct tag download (bypasses Release API)
|
||
# - Handles download, extraction/installation and version tracking in ~/.<app>
|
||
#
|
||
# Parameters:
|
||
# $1 APP - Application name (used for install path and version file)
|
||
# $2 REPO - Codeberg repository in form user/repo
|
||
# $3 MODE - Release type:
|
||
# tarball → source tarball (.tar.gz)
|
||
# binary → .deb file (auto-arch matched)
|
||
# prebuild → prebuilt archive (e.g. tar.gz)
|
||
# singlefile→ standalone binary (chmod +x)
|
||
# tag → direct tag (bypasses Release API)
|
||
# $4 VERSION - Optional release tag (default: latest)
|
||
# $5 TARGET_DIR - Optional install path (default: /opt/<app>)
|
||
# $6 ASSET_FILENAME - Required for:
|
||
# - prebuild → archive filename or pattern
|
||
# - singlefile→ binary filename or pattern
|
||
#
|
||
# Examples:
|
||
# # 1. Minimal: Fetch and deploy source tarball
|
||
# fetch_and_deploy_codeberg_release "autocaliweb" "gelbphoenix/autocaliweb"
|
||
#
|
||
# # 2. Binary install via .deb asset (architecture auto-detected)
|
||
# fetch_and_deploy_codeberg_release "myapp" "myuser/myapp" "binary"
|
||
#
|
||
# # 3. Prebuilt archive (.tar.gz) with asset filename match
|
||
# fetch_and_deploy_codeberg_release "myapp" "myuser/myapp" "prebuild" "latest" "/opt/myapp" "myapp_Linux_x86_64.tar.gz"
|
||
#
|
||
# # 4. Single binary (chmod +x)
|
||
# fetch_and_deploy_codeberg_release "myapp" "myuser/myapp" "singlefile" "v1.0.0" "/opt/myapp" "myapp-linux-amd64"
|
||
#
|
||
# # 5. Explicit tag version
|
||
# fetch_and_deploy_codeberg_release "autocaliweb" "gelbphoenix/autocaliweb" "tag" "v0.11.3" "/opt/autocaliweb"
|
||
# ------------------------------------------------------------------------------
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# _diagnose_deb_failure()
|
||
#
|
||
# - Called when both apt and dpkg fail to install a .deb package
|
||
# - Extracts package metadata and detects common failure patterns
|
||
# - Outputs enhanced error messages with actionable hints:
|
||
# * PostgreSQL version conflicts (e.g., postgresql-16-foo with pg17 active)
|
||
# * Missing declared dependencies
|
||
# * Generic fallback hint pointing to the log
|
||
#
|
||
# Usage: _diagnose_deb_failure "/path/to/file.deb"
|
||
# Returns: always 0 (diagnostic only — caller must return the error code)
|
||
# ------------------------------------------------------------------------------
|
||
_diagnose_deb_failure() {
|
||
local deb_path="$1"
|
||
local filename="${deb_path##*/}"
|
||
local pkg_name pkg_deps pkg_version
|
||
|
||
pkg_name=$(dpkg-deb -f "$deb_path" Package 2>/dev/null || echo "${filename%%_*}")
|
||
pkg_version=$(dpkg-deb -f "$deb_path" Version 2>/dev/null || true)
|
||
pkg_deps=$(dpkg-deb -f "$deb_path" Depends 2>/dev/null || true)
|
||
|
||
msg_error "Failed to install '${pkg_name}${pkg_version:+ (${pkg_version})}' — both apt and dpkg reported errors"
|
||
|
||
# Detect PostgreSQL version conflict (e.g., postgresql-16-vchord while pg17 is active)
|
||
local pg_ver_needed pg_ver_installed
|
||
pg_ver_needed=$(echo "$filename" | grep -oP '(?<=postgresql-)[0-9]+(?=-)' | head -1 || true)
|
||
if [[ -n "$pg_ver_needed" ]]; then
|
||
pg_ver_installed=$(psql -V 2>/dev/null | awk '{print $3}' | cut -d. -f1 || true)
|
||
if [[ -n "$pg_ver_installed" && "$pg_ver_needed" != "$pg_ver_installed" ]]; then
|
||
msg_error "Version conflict: '${pkg_name}' is built for PostgreSQL ${pg_ver_needed}, but PostgreSQL ${pg_ver_installed} is installed on this system."
|
||
msg_error "Hint: Your distribution installed a different PostgreSQL version than expected. The script may need updating to use postgresql-${pg_ver_installed}-* packages."
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# Show which declared dependencies are not satisfied
|
||
if [[ -n "$pkg_deps" ]]; then
|
||
local missing_deps=()
|
||
while IFS=',' read -ra dep_list; do
|
||
for dep_entry in "${dep_list[@]}"; do
|
||
local dep_pkg
|
||
dep_pkg=$(echo "$dep_entry" | awk '{print $1}' | tr -d ' ')
|
||
[[ -z "$dep_pkg" || "$dep_pkg" == "("* ]] && continue
|
||
if ! dpkg-query -W -f='${Status}' "$dep_pkg" 2>/dev/null | grep -q "install ok installed"; then
|
||
missing_deps+=("$dep_pkg")
|
||
fi
|
||
done
|
||
done <<<"$pkg_deps"
|
||
if [[ ${#missing_deps[@]} -gt 0 ]]; then
|
||
msg_error "Unmet dependencies: ${missing_deps[*]}"
|
||
msg_error "Hint: Run 'apt-get install -f' inside the container to attempt automatic dependency resolution."
|
||
else
|
||
msg_error "Hint: Declared dependencies appear present but installation still failed. Check the log above for the exact error."
|
||
fi
|
||
else
|
||
msg_error "Hint: Check the installation log above for the exact dependency or configuration error."
|
||
fi
|
||
}
|
||
|
||
fetch_and_deploy_codeberg_release() {
|
||
local app="$1"
|
||
local repo="$2"
|
||
local mode="${3:-tarball}" # tarball | binary | prebuild | singlefile | tag
|
||
local version="${var_appversion:-${4:-latest}}"
|
||
local target="${5:-/opt/$app}"
|
||
local asset_pattern="${6:-}"
|
||
|
||
local app_lc=$(echo "${app,,}" | tr -d ' ')
|
||
local version_file="$HOME/.${app_lc}"
|
||
|
||
local api_timeouts=(60 120 240)
|
||
|
||
local current_version=""
|
||
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
|
||
|
||
ensure_dependencies jq
|
||
|
||
### Tag Mode (bypass Release API) ###
|
||
if [[ "$mode" == "tag" ]]; then
|
||
if [[ "$version" == "latest" ]]; then
|
||
msg_error "Mode 'tag' requires explicit version (not 'latest')"
|
||
return 65
|
||
fi
|
||
|
||
local tag_name="$version"
|
||
[[ "$tag_name" =~ ^v ]] && version="${tag_name:1}" || version="$tag_name"
|
||
|
||
if [[ "$current_version" == "$version" ]]; then
|
||
$STD msg_ok "$app is already up-to-date (v$version)"
|
||
return 0
|
||
fi
|
||
|
||
# DNS check
|
||
if ! getent hosts "codeberg.org" &>/dev/null; then
|
||
msg_error "DNS resolution failed for codeberg.org – check /etc/resolv.conf or networking"
|
||
return 6
|
||
fi
|
||
|
||
local tmpdir
|
||
tmpdir=$(mktemp -d) || return 252
|
||
|
||
msg_info "Fetching Codeberg tag: $app ($tag_name)"
|
||
|
||
local safe_version="${version//@/_}"
|
||
safe_version="${safe_version//\//_}"
|
||
local filename="${app_lc}-${safe_version}.tar.gz"
|
||
local download_success=false
|
||
|
||
# Codeberg archive URL format: https://codeberg.org/{owner}/{repo}/archive/{tag}.tar.gz
|
||
local archive_url="https://codeberg.org/$repo/archive/${tag_name}.tar.gz"
|
||
if curl_download "$tmpdir/$filename" "$archive_url"; then
|
||
download_success=true
|
||
fi
|
||
|
||
if [[ "$download_success" != "true" ]]; then
|
||
msg_error "Download failed for $app ($tag_name)"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
fi
|
||
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
tar --no-same-owner -xzf "$tmpdir/$filename" -C "$tmpdir" || {
|
||
msg_error "Failed to extract tarball"
|
||
rm -rf "$tmpdir"
|
||
return 251
|
||
}
|
||
|
||
local unpack_dir
|
||
unpack_dir=$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d | head -n1)
|
||
|
||
shopt -s dotglob nullglob
|
||
cp -r "$unpack_dir"/* "$target/"
|
||
shopt -u dotglob nullglob
|
||
|
||
echo "$version" >"$version_file"
|
||
msg_ok "Deployed: $app ($version)"
|
||
rm -rf "$tmpdir"
|
||
return 0
|
||
fi
|
||
|
||
# Codeberg API: https://codeberg.org/api/v1/repos/{owner}/{repo}/releases
|
||
local api_url="https://codeberg.org/api/v1/repos/$repo/releases"
|
||
if [[ "$version" != "latest" ]]; then
|
||
# Get release by tag: /repos/{owner}/{repo}/releases/tags/{tag}
|
||
api_url="https://codeberg.org/api/v1/repos/$repo/releases/tags/$version"
|
||
fi
|
||
|
||
# dns pre check
|
||
if ! getent hosts "codeberg.org" &>/dev/null; then
|
||
msg_error "DNS resolution failed for codeberg.org – check /etc/resolv.conf or networking"
|
||
return 6
|
||
fi
|
||
|
||
local attempt=0 success=false resp http_code
|
||
|
||
while ((attempt < ${#api_timeouts[@]})); do
|
||
resp=$(curl --connect-timeout 10 --max-time "${api_timeouts[$attempt]}" -fsSL -w "%{http_code}" -o /tmp/codeberg_rel.json "$api_url") && success=true && break
|
||
attempt=$((attempt + 1))
|
||
if ((attempt < ${#api_timeouts[@]})); then
|
||
msg_warn "API request timed out after ${api_timeouts[$((attempt - 1))]}s, retrying... (attempt $((attempt + 1))/${#api_timeouts[@]})"
|
||
fi
|
||
done
|
||
|
||
if ! $success; then
|
||
msg_error "Failed to fetch release metadata from $api_url after ${#api_timeouts[@]} attempts"
|
||
return 22
|
||
fi
|
||
|
||
http_code="${resp:(-3)}"
|
||
[[ "$http_code" != "200" ]] && {
|
||
msg_error "Codeberg API returned HTTP $http_code"
|
||
return 22
|
||
}
|
||
|
||
local json tag_name
|
||
json=$(</tmp/codeberg_rel.json)
|
||
|
||
# For "latest", the API returns an array - take the first (most recent) release
|
||
if [[ "$version" == "latest" ]]; then
|
||
json=$(echo "$json" | jq '.[0]')
|
||
fi
|
||
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // .name // empty')
|
||
[[ "$tag_name" =~ ^v ]] && version="${tag_name:1}" || version="$tag_name"
|
||
|
||
if [[ "$current_version" == "$version" ]]; then
|
||
$STD msg_ok "$app is already up-to-date (v$version)"
|
||
return 0
|
||
fi
|
||
|
||
local tmpdir
|
||
tmpdir=$(mktemp -d) || return 252
|
||
local filename="" url=""
|
||
|
||
msg_info "Fetching Codeberg release: $app ($version)"
|
||
|
||
### Tarball Mode ###
|
||
if [[ "$mode" == "tarball" || "$mode" == "source" ]]; then
|
||
local safe_version="${version//@/_}"
|
||
safe_version="${safe_version//\//_}"
|
||
filename="${app_lc}-${safe_version}.tar.gz"
|
||
local download_success=false
|
||
|
||
# Codeberg archive URL format
|
||
local archive_url="https://codeberg.org/$repo/archive/${tag_name}.tar.gz"
|
||
if curl_download "$tmpdir/$filename" "$archive_url"; then
|
||
download_success=true
|
||
fi
|
||
|
||
if [[ "$download_success" != "true" ]]; then
|
||
msg_error "Download failed for $app ($tag_name)"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
fi
|
||
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
tar --no-same-owner -xzf "$tmpdir/$filename" -C "$tmpdir" || {
|
||
msg_error "Failed to extract tarball"
|
||
rm -rf "$tmpdir"
|
||
return 251
|
||
}
|
||
local unpack_dir
|
||
unpack_dir=$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d | head -n1)
|
||
|
||
shopt -s dotglob nullglob
|
||
cp -r "$unpack_dir"/* "$target/"
|
||
shopt -u dotglob nullglob
|
||
|
||
### Binary Mode ###
|
||
elif [[ "$mode" == "binary" ]]; then
|
||
local arch
|
||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
|
||
local assets url_match=""
|
||
# Codeberg assets are in .assets[].browser_download_url
|
||
assets=$(echo "$json" | jq -r '.assets[].browser_download_url')
|
||
|
||
# If explicit filename pattern is provided, match that first
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
for u in $assets; do
|
||
case "${u##*/}" in
|
||
$asset_pattern)
|
||
url_match="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
|
||
# Fall back to architecture heuristic
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] || continue
|
||
if [[ "${arch,,}" =~ ^(amd64|x86_64)$ ]]; then
|
||
[[ "$u" =~ (amd64|x86_64).*\.deb$ ]] || continue
|
||
elif [[ "${arch,,}" =~ ^(arm64|aarch64)$ ]]; then
|
||
[[ "$u" =~ (arm64|aarch64).*\.deb$ ]] || continue
|
||
fi
|
||
url_match="$u"
|
||
break
|
||
done
|
||
fi
|
||
|
||
# Fallback: any .deb file
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] && url_match="$u" && break
|
||
done
|
||
fi
|
||
|
||
if [[ -z "$url_match" ]]; then
|
||
msg_error "No suitable .deb asset found for $app"
|
||
rm -rf "$tmpdir"
|
||
return 252
|
||
fi
|
||
|
||
filename="${url_match##*/}"
|
||
curl_download "$tmpdir/$filename" "$url_match" || {
|
||
msg_error "Download failed: $url_match"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
chmod 644 "$tmpdir/$filename"
|
||
$STD apt install -y "$tmpdir/$filename" || {
|
||
$STD dpkg -i "$tmpdir/$filename" || {
|
||
_diagnose_deb_failure "$tmpdir/$filename"
|
||
rm -rf "$tmpdir"
|
||
return 100
|
||
}
|
||
}
|
||
|
||
### Prebuild Mode ###
|
||
elif [[ "$mode" == "prebuild" ]]; then
|
||
local pattern="${6%\"}"
|
||
pattern="${pattern#\"}"
|
||
[[ -z "$pattern" ]] && {
|
||
msg_error "Mode 'prebuild' requires 6th parameter (asset filename pattern)"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
}
|
||
|
||
local asset_url=""
|
||
for u in $(echo "$json" | jq -r '.assets[].browser_download_url'); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in
|
||
$pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
[[ -z "$asset_url" ]] && {
|
||
msg_error "No asset matching '$pattern' found"
|
||
rm -rf "$tmpdir"
|
||
return 252
|
||
}
|
||
|
||
filename="${asset_url##*/}"
|
||
curl_download "$tmpdir/$filename" "$asset_url" || {
|
||
msg_error "Download failed: $asset_url"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
local unpack_tmp
|
||
unpack_tmp=$(mktemp -d)
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
if [[ "$filename" == *.zip ]]; then
|
||
ensure_dependencies unzip
|
||
unzip -q "$tmpdir/$filename" -d "$unpack_tmp" || {
|
||
msg_error "Failed to extract ZIP archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
}
|
||
elif [[ "$filename" == *.tar.* || "$filename" == *.tgz ]]; then
|
||
tar --no-same-owner -xf "$tmpdir/$filename" -C "$unpack_tmp" || {
|
||
msg_error "Failed to extract TAR archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
}
|
||
else
|
||
msg_error "Unsupported archive format: $filename"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
fi
|
||
|
||
local top_dirs
|
||
top_dirs=$(find "$unpack_tmp" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||
local top_entries inner_dir
|
||
top_entries=$(find "$unpack_tmp" -mindepth 1 -maxdepth 1)
|
||
if [[ "$(echo "$top_entries" | wc -l)" -eq 1 && -d "$top_entries" ]]; then
|
||
inner_dir="$top_entries"
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$inner_dir/*" >/dev/null; then
|
||
cp -r "$inner_dir"/* "$target/" || {
|
||
msg_error "Failed to copy contents from $inner_dir to $target"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
}
|
||
else
|
||
msg_error "Inner directory is empty: $inner_dir"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
else
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$unpack_tmp/*" >/dev/null; then
|
||
cp -r "$unpack_tmp"/* "$target/" || {
|
||
msg_error "Failed to copy contents to $target"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
}
|
||
else
|
||
msg_error "Unpacked archive is empty"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
fi
|
||
|
||
### Singlefile Mode ###
|
||
elif [[ "$mode" == "singlefile" ]]; then
|
||
local pattern="${6%\"}"
|
||
pattern="${pattern#\"}"
|
||
[[ -z "$pattern" ]] && {
|
||
msg_error "Mode 'singlefile' requires 6th parameter (asset filename pattern)"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
}
|
||
|
||
local asset_url=""
|
||
for u in $(echo "$json" | jq -r '.assets[].browser_download_url'); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in
|
||
$pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
[[ -z "$asset_url" ]] && {
|
||
msg_error "No asset matching '$pattern' found"
|
||
rm -rf "$tmpdir"
|
||
return 252
|
||
}
|
||
|
||
filename="${asset_url##*/}"
|
||
mkdir -p "$target"
|
||
|
||
local use_filename="${USE_ORIGINAL_FILENAME:-false}"
|
||
local target_file="$app"
|
||
[[ "$use_filename" == "true" ]] && target_file="$filename"
|
||
|
||
curl_download "$target/$target_file" "$asset_url" || {
|
||
msg_error "Download failed: $asset_url"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then
|
||
chmod +x "$target/$target_file"
|
||
fi
|
||
|
||
else
|
||
msg_error "Unknown mode: $mode"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
fi
|
||
|
||
echo "$version" >"$version_file"
|
||
msg_ok "Deployed: $app ($version)"
|
||
rm -rf "$tmpdir"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Downloads and deploys latest GitHub release (source, binary, tarball, asset).
|
||
#
|
||
# Description:
|
||
# - Fetches latest release metadata from GitHub API
|
||
# - Supports the following modes:
|
||
# - tarball: Source code tarball (default if omitted)
|
||
# - source: Alias for tarball (same behavior)
|
||
# - binary: .deb package install (arch-dependent)
|
||
# - prebuild: Prebuilt .tar.gz archive (e.g. Go binaries)
|
||
# - singlefile: Standalone binary (no archive, direct chmod +x install)
|
||
# - Handles download, extraction/installation and version tracking in ~/.<app>
|
||
#
|
||
# Parameters:
|
||
# $1 APP - Application name (used for install path and version file)
|
||
# $2 REPO - GitHub repository in form user/repo
|
||
# $3 MODE - Release type:
|
||
# tarball → source tarball (.tar.gz)
|
||
# binary → .deb file (auto-arch matched)
|
||
# prebuild → prebuilt archive (e.g. tar.gz)
|
||
# singlefile→ standalone binary (chmod +x)
|
||
# $4 VERSION - Optional release tag (default: latest)
|
||
# $5 TARGET_DIR - Optional install path (default: /opt/<app>)
|
||
# $6 ASSET_FILENAME - Required for:
|
||
# - prebuild → archive filename or pattern
|
||
# - singlefile→ binary filename or pattern
|
||
#
|
||
# Optional:
|
||
# - Set GITHUB_TOKEN env var to increase API rate limit (recommended for CI/CD).
|
||
#
|
||
# Examples:
|
||
# # 1. Minimal: Fetch and deploy source tarball
|
||
# fetch_and_deploy_gh_release "myapp" "myuser/myapp"
|
||
#
|
||
# # 2. Binary install via .deb asset (architecture auto-detected)
|
||
# fetch_and_deploy_gh_release "myapp" "myuser/myapp" "binary"
|
||
#
|
||
# # 3. Prebuilt archive (.tar.gz) with asset filename match
|
||
# fetch_and_deploy_gh_release "hanko" "teamhanko/hanko" "prebuild" "latest" "/opt/hanko" "hanko_Linux_x86_64.tar.gz"
|
||
#
|
||
# # 4. Single binary (chmod +x) like Argus, Promtail etc.
|
||
# fetch_and_deploy_gh_release "argus" "release-argus/Argus" "singlefile" "0.26.3" "/opt/argus" "Argus-.*linux-amd64"
|
||
#
|
||
# Notes:
|
||
# - For binary/prebuild/singlefile modes: if the target release has no
|
||
# matching asset, the function scans older releases and prompts the user
|
||
# (60s timeout, default yes) to use a previous version that has the asset.
|
||
# ------------------------------------------------------------------------------
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Scans older GitHub releases for a matching asset when the latest release
|
||
# is missing the expected file. Used internally by fetch_and_deploy_gh_release.
|
||
#
|
||
# Arguments:
|
||
# $1 - GitHub repo (owner/repo)
|
||
# $2 - mode (binary|prebuild|singlefile)
|
||
# $3 - asset_pattern (glob pattern for asset filename)
|
||
# $4 - tag to skip (the already-checked release)
|
||
#
|
||
# Output:
|
||
# Prints the release JSON of the first older release that has a matching asset.
|
||
# Returns 0 on success, 1 if no matching release found or user declined.
|
||
# ------------------------------------------------------------------------------
|
||
_gh_scan_older_releases() {
|
||
local repo="$1"
|
||
local mode="$2"
|
||
local asset_pattern="$3"
|
||
local skip_tag="$4"
|
||
|
||
local header=()
|
||
[[ -n "${GITHUB_TOKEN:-}" ]] && header=(-H "Authorization: token $GITHUB_TOKEN")
|
||
|
||
local releases_list
|
||
releases_list=$(curl --connect-timeout 10 --max-time 30 -fsSL \
|
||
-H 'Accept: application/vnd.github+json' \
|
||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||
"${header[@]}" \
|
||
"https://api.github.com/repos/${repo}/releases?per_page=15" 2>/dev/null) || {
|
||
msg_warn "Failed to fetch older releases for ${repo}"
|
||
return 22
|
||
}
|
||
|
||
local count
|
||
count=$(echo "$releases_list" | jq 'length')
|
||
|
||
for ((i = 0; i < count; i++)); do
|
||
local rel_tag rel_draft rel_prerelease
|
||
rel_tag=$(echo "$releases_list" | jq -r ".[$i].tag_name")
|
||
rel_draft=$(echo "$releases_list" | jq -r ".[$i].draft")
|
||
rel_prerelease=$(echo "$releases_list" | jq -r ".[$i].prerelease")
|
||
|
||
# Skip drafts, prereleases, and the tag we already checked
|
||
[[ "$rel_draft" == "true" || "$rel_prerelease" == "true" ]] && continue
|
||
[[ "$rel_tag" == "$skip_tag" ]] && continue
|
||
|
||
local has_match=false
|
||
|
||
if [[ "$mode" == "binary" ]]; then
|
||
local arch
|
||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
|
||
# Check with explicit pattern first, then arch heuristic, then any .deb
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
has_match=$(echo "$releases_list" | jq -r --arg pat "$asset_pattern" ".[$i].assets[].name" | while read -r name; do
|
||
case "$name" in $asset_pattern)
|
||
echo true
|
||
break
|
||
;;
|
||
esac
|
||
done)
|
||
fi
|
||
if [[ "$has_match" != "true" ]]; then
|
||
if [[ "${arch,,}" =~ ^(amd64|x86_64)$ ]]; then
|
||
has_match=$(echo "$releases_list" | jq -r ".[$i].assets[].browser_download_url" | grep -qE '(amd64|x86_64).*\.deb$' && echo true)
|
||
elif [[ "${arch,,}" =~ ^(arm64|aarch64)$ ]]; then
|
||
has_match=$(echo "$releases_list" | jq -r ".[$i].assets[].browser_download_url" | grep -qE '(arm64|aarch64).*\.deb$' && echo true)
|
||
fi
|
||
fi
|
||
if [[ "$has_match" != "true" ]]; then
|
||
has_match=$(echo "$releases_list" | jq -r ".[$i].assets[].browser_download_url" | grep -qE '\.deb$' && echo true)
|
||
fi
|
||
|
||
elif [[ "$mode" == "prebuild" || "$mode" == "singlefile" ]]; then
|
||
has_match=$(echo "$releases_list" | jq -r ".[$i].assets[].name" | while read -r name; do
|
||
case "$name" in $asset_pattern)
|
||
echo true
|
||
break
|
||
;;
|
||
esac
|
||
done)
|
||
fi
|
||
|
||
if [[ "$has_match" == "true" ]]; then
|
||
local rel_version="$rel_tag"
|
||
[[ "$rel_tag" =~ ^v ]] && rel_version="${rel_tag:1}"
|
||
|
||
local use_fallback="y"
|
||
if [[ -t 0 ]]; then
|
||
msg_warn "Release ${skip_tag} has no matching asset. Previous release ${rel_tag} has a compatible asset."
|
||
read -rp "Use version ${rel_tag} instead? [Y/n] (auto-yes in 60s): " -t 60 use_fallback || use_fallback="y"
|
||
use_fallback="${use_fallback:-y}"
|
||
fi
|
||
|
||
if [[ "${use_fallback,,}" == "y" || "${use_fallback,,}" == "yes" ]]; then
|
||
echo "$releases_list" | jq ".[$i]"
|
||
return 0
|
||
else
|
||
return 250
|
||
fi
|
||
fi
|
||
done
|
||
|
||
return 250
|
||
}
|
||
|
||
fetch_and_deploy_gh_release() {
|
||
local app="$1"
|
||
local repo="$2"
|
||
local mode="${3:-tarball}" # tarball | binary | prebuild | singlefile
|
||
local version="${var_appversion:-${4:-latest}}"
|
||
local target="${5:-/opt/$app}"
|
||
local asset_pattern="${6:-}"
|
||
|
||
# Validate app name to prevent /root/. directory issues
|
||
if [[ -z "$app" ]]; then
|
||
# Derive app name from repo if not provided
|
||
app="${repo##*/}"
|
||
if [[ -z "$app" ]]; then
|
||
msg_error "fetch_and_deploy_gh_release requires app name or valid repo"
|
||
return 65
|
||
fi
|
||
fi
|
||
|
||
local app_lc=$(echo "${app,,}" | tr -d ' ')
|
||
local version_file="$HOME/.${app_lc}"
|
||
|
||
local api_timeouts=(60 120 240)
|
||
|
||
local current_version=""
|
||
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
|
||
|
||
ensure_dependencies jq
|
||
|
||
local api_url="https://api.github.com/repos/$repo/releases"
|
||
[[ "$version" != "latest" ]] && api_url="$api_url/tags/$version" || api_url="$api_url/latest"
|
||
local header=()
|
||
[[ -n "${GITHUB_TOKEN:-}" ]] && header=(-H "Authorization: token $GITHUB_TOKEN")
|
||
|
||
# dns pre check
|
||
local gh_host
|
||
gh_host=$(awk -F/ '{print $3}' <<<"$api_url")
|
||
if ! getent hosts "$gh_host" &>/dev/null; then
|
||
msg_error "DNS resolution failed for $gh_host – check /etc/resolv.conf or networking"
|
||
return 6
|
||
fi
|
||
|
||
local max_retries=${#api_timeouts[@]} retry_delay=2 attempt=1 success=false http_code
|
||
|
||
while ((attempt <= max_retries)); do
|
||
http_code=$(curl --connect-timeout 10 --max-time "${api_timeouts[$((attempt - 1))]:-240}" -sSL -w "%{http_code}" -o /tmp/gh_rel.json "${header[@]}" "$api_url" 2>/dev/null) || true
|
||
if [[ "$http_code" == "200" ]]; then
|
||
success=true
|
||
break
|
||
elif [[ "$http_code" == "401" ]]; then
|
||
msg_error "GitHub API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITHUB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication."
|
||
fi
|
||
if prompt_for_github_token; then
|
||
header=(-H "Authorization: token $GITHUB_TOKEN")
|
||
continue
|
||
fi
|
||
break
|
||
elif [[ "$http_code" == "403" ]]; then
|
||
if ((attempt < max_retries)); then
|
||
msg_warn "GitHub API rate limit hit, retrying in ${retry_delay}s... (attempt $attempt/$max_retries)"
|
||
sleep "$retry_delay"
|
||
retry_delay=$((retry_delay * 2))
|
||
else
|
||
msg_error "GitHub API rate limit exceeded (HTTP 403)."
|
||
if prompt_for_github_token; then
|
||
header=(-H "Authorization: token $GITHUB_TOKEN")
|
||
retry_delay=2
|
||
attempt=1
|
||
continue
|
||
fi
|
||
fi
|
||
else
|
||
sleep "$retry_delay"
|
||
fi
|
||
((attempt++))
|
||
done
|
||
|
||
if ! $success; then
|
||
if [[ "$http_code" == "000" || -z "$http_code" ]]; then
|
||
msg_error "GitHub API connection failed (no response)."
|
||
msg_error "Check your network/DNS: curl -sSL https://api.github.com/rate_limit"
|
||
elif [[ "$http_code" != "401" ]]; then
|
||
msg_error "Failed to fetch release metadata (HTTP $http_code)"
|
||
fi
|
||
return 22
|
||
fi
|
||
|
||
local json tag_name
|
||
json=$(</tmp/gh_rel.json)
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // .name // empty')
|
||
# Only strip leading 'v' when followed by a digit (e.g. v1.2.3), not words like "version/..."
|
||
[[ "$tag_name" =~ ^v[0-9] ]] && version="${tag_name:1}" || version="$tag_name"
|
||
# Sanitize version for use in filenames (replace / with -)
|
||
local version_safe="${version//\//-}"
|
||
|
||
if [[ "$current_version" == "$version" ]]; then
|
||
$STD msg_ok "$app is already up-to-date (v$version)"
|
||
return 0
|
||
fi
|
||
|
||
local tmpdir
|
||
tmpdir=$(mktemp -d) || return 1
|
||
local filename="" url=""
|
||
|
||
msg_info "Fetching GitHub release: $app ($version)"
|
||
|
||
local clean_install=false
|
||
[[ -n "${CLEAN_INSTALL:-}" && "$CLEAN_INSTALL" == "1" ]] && clean_install=true
|
||
|
||
### Tarball Mode ###
|
||
if [[ "$mode" == "tarball" || "$mode" == "source" ]]; then
|
||
# GitHub API's tarball_url/zipball_url can return HTTP 300 Multiple Choices
|
||
# when a branch and tag share the same name. Use explicit refs/tags/ URL instead.
|
||
local direct_tarball_url="https://github.com/$repo/archive/refs/tags/$tag_name.tar.gz"
|
||
filename="${app_lc}-${version_safe}.tar.gz"
|
||
|
||
curl_download "$tmpdir/$filename" "$direct_tarball_url" || {
|
||
msg_error "Download failed: $direct_tarball_url"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
tar --no-same-owner -xzf "$tmpdir/$filename" -C "$tmpdir" || {
|
||
msg_error "Failed to extract tarball"
|
||
rm -rf "$tmpdir"
|
||
return 251
|
||
}
|
||
local unpack_dir
|
||
unpack_dir=$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d | head -n1)
|
||
|
||
shopt -s dotglob nullglob
|
||
cp -r "$unpack_dir"/* "$target/"
|
||
shopt -u dotglob nullglob
|
||
|
||
### Binary Mode ###
|
||
elif [[ "$mode" == "binary" ]]; then
|
||
local arch
|
||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
|
||
local assets url_match=""
|
||
assets=$(echo "$json" | jq -r '.assets[].browser_download_url')
|
||
|
||
# If explicit filename pattern is provided (param $6), match that first
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
for u in $assets; do
|
||
case "${u##*/}" in
|
||
$asset_pattern)
|
||
url_match="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
|
||
# If no match via explicit pattern, fall back to architecture heuristic
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] || continue
|
||
if [[ "${arch,,}" =~ ^(amd64|x86_64)$ ]]; then
|
||
[[ "$u" =~ (amd64|x86_64).*\.deb$ ]] || continue
|
||
elif [[ "${arch,,}" =~ ^(arm64|aarch64)$ ]]; then
|
||
[[ "$u" =~ (arm64|aarch64).*\.deb$ ]] || continue
|
||
fi
|
||
url_match="$u"
|
||
break
|
||
done
|
||
fi
|
||
|
||
# Fallback: any .deb file
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] && url_match="$u" && break
|
||
done
|
||
fi
|
||
|
||
# Fallback: scan older releases for a matching .deb asset
|
||
if [[ -z "$url_match" ]]; then
|
||
local fallback_json
|
||
if fallback_json=$(_gh_scan_older_releases "$repo" "binary" "$asset_pattern" "$tag_name"); then
|
||
json="$fallback_json"
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // .name // empty')
|
||
[[ "$tag_name" =~ ^v ]] && version="${tag_name:1}" || version="$tag_name"
|
||
msg_info "Fetching GitHub release: $app ($version)"
|
||
assets=$(echo "$json" | jq -r '.assets[].browser_download_url')
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
for u in $assets; do
|
||
case "${u##*/}" in $asset_pattern)
|
||
url_match="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] || continue
|
||
if [[ "${arch,,}" =~ ^(amd64|x86_64)$ ]]; then
|
||
[[ "$u" =~ (amd64|x86_64).*\.deb$ ]] || continue
|
||
elif [[ "${arch,,}" =~ ^(arm64|aarch64)$ ]]; then
|
||
[[ "$u" =~ (arm64|aarch64).*\.deb$ ]] || continue
|
||
fi
|
||
url_match="$u"
|
||
break
|
||
done
|
||
fi
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] && url_match="$u" && break
|
||
done
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$url_match" ]]; then
|
||
msg_error "No suitable .deb asset found for $app"
|
||
rm -rf "$tmpdir"
|
||
return 252
|
||
fi
|
||
|
||
filename="${url_match##*/}"
|
||
curl_download "$tmpdir/$filename" "$url_match" || {
|
||
msg_error "Download failed: $url_match"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
chmod 644 "$tmpdir/$filename"
|
||
# SYSTEMD_OFFLINE=1 prevents systemd-tmpfiles failures in unprivileged LXC (Debian 13+/systemd 257+)
|
||
# Support DPKG_CONFOLD/DPKG_CONFNEW env vars for config file handling during .deb upgrades
|
||
local dpkg_opts=""
|
||
[[ "${DPKG_FORCE_CONFOLD:-}" == "1" ]] && dpkg_opts="-o Dpkg::Options::=--force-confold"
|
||
[[ "${DPKG_FORCE_CONFNEW:-}" == "1" ]] && dpkg_opts="-o Dpkg::Options::=--force-confnew"
|
||
DEBIAN_FRONTEND=noninteractive SYSTEMD_OFFLINE=1 $STD apt install -y $dpkg_opts "$tmpdir/$filename" || {
|
||
SYSTEMD_OFFLINE=1 $STD dpkg -i "$tmpdir/$filename" || {
|
||
_diagnose_deb_failure "$tmpdir/$filename"
|
||
rm -rf "$tmpdir"
|
||
return 100
|
||
}
|
||
}
|
||
|
||
### Prebuild Mode ###
|
||
elif [[ "$mode" == "prebuild" ]]; then
|
||
local pattern="${6%\"}"
|
||
pattern="${pattern#\"}"
|
||
[[ -z "$pattern" ]] && {
|
||
msg_error "Mode 'prebuild' requires 6th parameter (asset filename pattern)"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
}
|
||
|
||
local asset_url=""
|
||
for u in $(echo "$json" | jq -r '.assets[].browser_download_url'); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in
|
||
$pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Fallback: scan older releases for a matching asset
|
||
if [[ -z "$asset_url" ]]; then
|
||
local fallback_json
|
||
if fallback_json=$(_gh_scan_older_releases "$repo" "prebuild" "$pattern" "$tag_name"); then
|
||
json="$fallback_json"
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // .name // empty')
|
||
[[ "$tag_name" =~ ^v ]] && version="${tag_name:1}" || version="$tag_name"
|
||
msg_info "Fetching GitHub release: $app ($version)"
|
||
for u in $(echo "$json" | jq -r '.assets[].browser_download_url'); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in $pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
fi
|
||
|
||
[[ -z "$asset_url" ]] && {
|
||
msg_error "No asset matching '$pattern' found"
|
||
rm -rf "$tmpdir"
|
||
return 252
|
||
}
|
||
|
||
filename="${asset_url##*/}"
|
||
curl_download "$tmpdir/$filename" "$asset_url" || {
|
||
msg_error "Download failed: $asset_url"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
local unpack_tmp
|
||
unpack_tmp=$(mktemp -d)
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
if [[ "$filename" == *.zip ]]; then
|
||
ensure_dependencies unzip
|
||
unzip -q "$tmpdir/$filename" -d "$unpack_tmp" || {
|
||
msg_error "Failed to extract ZIP archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
}
|
||
elif [[ "$filename" == *.tar.* || "$filename" == *.tgz || "$filename" == *.txz ]]; then
|
||
tar --no-same-owner -xf "$tmpdir/$filename" -C "$unpack_tmp" || {
|
||
msg_error "Failed to extract TAR archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
}
|
||
else
|
||
msg_error "Unsupported archive format: $filename"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 65
|
||
fi
|
||
|
||
local top_dirs
|
||
top_dirs=$(find "$unpack_tmp" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||
local top_entries inner_dir
|
||
top_entries=$(find "$unpack_tmp" -mindepth 1 -maxdepth 1)
|
||
if [[ "$(echo "$top_entries" | wc -l)" -eq 1 && -d "$top_entries" ]]; then
|
||
# Strip leading folder
|
||
inner_dir="$top_entries"
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$inner_dir/*" >/dev/null; then
|
||
cp -r "$inner_dir"/* "$target/" || {
|
||
msg_error "Failed to copy contents from $inner_dir to $target"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
}
|
||
else
|
||
msg_error "Inner directory is empty: $inner_dir"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
else
|
||
# Copy all contents
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$unpack_tmp/*" >/dev/null; then
|
||
cp -r "$unpack_tmp"/* "$target/" || {
|
||
msg_error "Failed to copy contents to $target"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
}
|
||
else
|
||
msg_error "Unpacked archive is empty"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
fi
|
||
|
||
### Singlefile Mode ###
|
||
elif [[ "$mode" == "singlefile" ]]; then
|
||
local pattern="${6%\"}"
|
||
pattern="${pattern#\"}"
|
||
[[ -z "$pattern" ]] && {
|
||
msg_error "Mode 'singlefile' requires 6th parameter (asset filename pattern)"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
}
|
||
|
||
local asset_url=""
|
||
for u in $(echo "$json" | jq -r '.assets[].browser_download_url'); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in
|
||
$pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Fallback: scan older releases for a matching asset
|
||
if [[ -z "$asset_url" ]]; then
|
||
local fallback_json
|
||
if fallback_json=$(_gh_scan_older_releases "$repo" "singlefile" "$pattern" "$tag_name"); then
|
||
json="$fallback_json"
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // .name // empty')
|
||
[[ "$tag_name" =~ ^v ]] && version="${tag_name:1}" || version="$tag_name"
|
||
msg_info "Fetching GitHub release: $app ($version)"
|
||
for u in $(echo "$json" | jq -r '.assets[].browser_download_url'); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in $pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
fi
|
||
[[ -z "$asset_url" ]] && {
|
||
msg_error "No asset matching '$pattern' found"
|
||
rm -rf "$tmpdir"
|
||
return 252
|
||
}
|
||
|
||
filename="${asset_url##*/}"
|
||
mkdir -p "$target"
|
||
|
||
local use_filename="${USE_ORIGINAL_FILENAME:-false}"
|
||
local target_file="$app"
|
||
[[ "$use_filename" == "true" ]] && target_file="$filename"
|
||
|
||
curl_download "$target/$target_file" "$asset_url" || {
|
||
msg_error "Download failed: $asset_url"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then
|
||
chmod +x "$target/$target_file"
|
||
fi
|
||
|
||
else
|
||
msg_error "Unknown mode: $mode"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
fi
|
||
|
||
echo "$version" >"$version_file"
|
||
msg_ok "Deployed: $app ($version)"
|
||
rm -rf "$tmpdir"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs Adminer (Debian/Ubuntu via APT, Alpine via direct download).
|
||
#
|
||
# Description:
|
||
# - Adds Adminer to Apache or web root
|
||
# - Supports Alpine and Debian-based systems
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_adminer() {
|
||
if grep -qi alpine /etc/os-release; then
|
||
msg_info "Setup Adminer (Alpine)"
|
||
mkdir -p /var/www/localhost/htdocs/adminer
|
||
if ! curl_with_retry "https://github.com/vrana/adminer/releases/latest/download/adminer.php" "/var/www/localhost/htdocs/adminer/index.php"; then
|
||
msg_error "Failed to download Adminer"
|
||
msg_error "Hint: Check connectivity to github.com/vrana/adminer (GitHub Releases)"
|
||
return 250
|
||
fi
|
||
cache_installed_version "adminer" "latest-alpine"
|
||
msg_ok "Setup Adminer (Alpine)"
|
||
else
|
||
msg_info "Setup Adminer (Debian/Ubuntu)"
|
||
ensure_dependencies adminer
|
||
$STD a2enconf adminer || {
|
||
msg_error "Failed to enable Adminer Apache config"
|
||
return 150
|
||
}
|
||
$STD systemctl reload apache2 || {
|
||
msg_error "Failed to reload Apache"
|
||
return 150
|
||
}
|
||
local VERSION
|
||
VERSION=$(dpkg -s adminer 2>/dev/null | grep '^Version:' | awk '{print $2}' 2>/dev/null || echo 'unknown')
|
||
cache_installed_version "adminer" "${VERSION:-unknown}"
|
||
msg_ok "Setup Adminer (Debian/Ubuntu)"
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or upgrades ClickHouse database server.
|
||
#
|
||
# Description:
|
||
# - Adds ClickHouse official repository
|
||
# - Installs specified version
|
||
# - Configures systemd service
|
||
# - Supports Debian/Ubuntu with fallback mechanism
|
||
#
|
||
# Variables:
|
||
# CLICKHOUSE_VERSION - ClickHouse version to install (default: latest)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_clickhouse() {
|
||
local CLICKHOUSE_VERSION="${CLICKHOUSE_VERSION:-latest}"
|
||
local DISTRO_ID DISTRO_CODENAME
|
||
DISTRO_ID=$(get_os_info id)
|
||
DISTRO_CODENAME=$(get_os_info codename)
|
||
|
||
# Ensure non-interactive mode for all apt operations
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export NEEDRESTART_MODE=a
|
||
export NEEDRESTART_SUSPEND=1
|
||
|
||
# Resolve "latest" version
|
||
if [[ "$CLICKHOUSE_VERSION" == "latest" ]]; then
|
||
CLICKHOUSE_VERSION=$(curl -fsSL --max-time 15 https://packages.clickhouse.com/tgz/stable/ 2>/dev/null |
|
||
grep -oP 'clickhouse-common-static-\K[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' |
|
||
sort -V | tail -n1 || echo "")
|
||
|
||
# Fallback to GitHub API if package server failed
|
||
if [[ -z "$CLICKHOUSE_VERSION" ]]; then
|
||
CLICKHOUSE_VERSION=$(get_latest_github_release "ClickHouse/ClickHouse") || true
|
||
fi
|
||
|
||
[[ -z "$CLICKHOUSE_VERSION" ]] && {
|
||
msg_error "Could not determine latest ClickHouse version from any source"
|
||
return 250
|
||
}
|
||
fi
|
||
|
||
# Get currently installed version
|
||
local CURRENT_VERSION=""
|
||
if command -v clickhouse-server >/dev/null 2>&1; then
|
||
CURRENT_VERSION=$(clickhouse-server --version 2>/dev/null | grep -oP 'version \K[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
|
||
fi
|
||
|
||
# Scenario 1: Already at target version - just update packages
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" == "$CLICKHOUSE_VERSION" ]]; then
|
||
msg_info "Update ClickHouse $CLICKHOUSE_VERSION"
|
||
ensure_apt_working || return 100
|
||
|
||
# Perform upgrade with retry logic (non-fatal if fails)
|
||
upgrade_packages_with_retry "clickhouse-server" "clickhouse-client" || {
|
||
msg_warn "ClickHouse package upgrade had issues, continuing with current version"
|
||
}
|
||
cache_installed_version "clickhouse" "$CLICKHOUSE_VERSION"
|
||
msg_ok "Update ClickHouse $CLICKHOUSE_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version - clean upgrade
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "$CLICKHOUSE_VERSION" ]]; then
|
||
msg_info "Upgrade ClickHouse from $CURRENT_VERSION to $CLICKHOUSE_VERSION"
|
||
stop_all_services "clickhouse-server"
|
||
remove_old_tool_version "clickhouse"
|
||
else
|
||
msg_info "Setup ClickHouse $CLICKHOUSE_VERSION"
|
||
fi
|
||
|
||
ensure_dependencies apt-transport-https ca-certificates dirmngr gnupg
|
||
|
||
# Prepare repository (cleanup + validation)
|
||
prepare_repository_setup "clickhouse" || {
|
||
msg_error "Failed to prepare ClickHouse repository"
|
||
return 100
|
||
}
|
||
|
||
# Setup repository (ClickHouse uses 'stable' suite)
|
||
setup_deb822_repo \
|
||
"clickhouse" \
|
||
"https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key" \
|
||
"https://packages.clickhouse.com/deb" \
|
||
"stable" \
|
||
"main"
|
||
|
||
# Install packages with retry logic
|
||
$STD apt update || {
|
||
msg_error "APT update failed for ClickHouse repository"
|
||
return 100
|
||
}
|
||
|
||
install_packages_with_retry "clickhouse-server" "clickhouse-client" || {
|
||
msg_error "Failed to install ClickHouse packages"
|
||
return 100
|
||
}
|
||
|
||
# Verify installation
|
||
if ! command -v clickhouse-server >/dev/null 2>&1; then
|
||
msg_error "ClickHouse installation completed but clickhouse-server command not found"
|
||
return 127
|
||
fi
|
||
|
||
# Setup data directory
|
||
mkdir -p /var/lib/clickhouse
|
||
if id clickhouse >/dev/null 2>&1; then
|
||
chown -R clickhouse:clickhouse /var/lib/clickhouse
|
||
fi
|
||
|
||
# Enable and start service
|
||
$STD systemctl enable clickhouse-server || {
|
||
msg_warn "Failed to enable clickhouse-server service"
|
||
}
|
||
safe_service_restart clickhouse-server || true
|
||
|
||
cache_installed_version "clickhouse" "$CLICKHOUSE_VERSION"
|
||
msg_ok "Setup ClickHouse $CLICKHOUSE_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or updates Composer globally (robust, idempotent).
|
||
#
|
||
# - Installs to /usr/local/bin/composer
|
||
# - Removes old binaries/symlinks in /usr/bin, /bin, /root/.composer, etc.
|
||
# - Ensures /usr/local/bin is in PATH (permanent)
|
||
# - Auto-updates to latest version
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_composer() {
|
||
local COMPOSER_BIN="/usr/local/bin/composer"
|
||
export COMPOSER_ALLOW_SUPERUSER=1
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
if [[ -x "$COMPOSER_BIN" ]]; then
|
||
INSTALLED_VERSION=$("$COMPOSER_BIN" --version 2>/dev/null | awk '{print $3}')
|
||
fi
|
||
|
||
# Scenario 1: Already installed - just self-update
|
||
if [[ -n "$INSTALLED_VERSION" ]]; then
|
||
msg_info "Update Composer $INSTALLED_VERSION"
|
||
$STD "$COMPOSER_BIN" self-update --no-interaction || {
|
||
msg_warn "Composer self-update failed, continuing with current version"
|
||
}
|
||
local UPDATED_VERSION
|
||
UPDATED_VERSION=$("$COMPOSER_BIN" --version 2>/dev/null | awk '{print $3}')
|
||
cache_installed_version "composer" "$UPDATED_VERSION"
|
||
msg_ok "Update Composer $UPDATED_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Fresh install
|
||
msg_info "Setup Composer"
|
||
|
||
for old in /usr/bin/composer /bin/composer /root/.composer/vendor/bin/composer; do
|
||
[[ -e "$old" && "$old" != "$COMPOSER_BIN" ]] && rm -f "$old"
|
||
done
|
||
|
||
ensure_usr_local_bin_persist
|
||
export PATH="/usr/local/bin:$PATH"
|
||
|
||
if ! curl_with_retry "https://getcomposer.org/installer" "/tmp/composer-setup.php"; then
|
||
msg_error "Failed to download Composer installer"
|
||
msg_error "Hint: Check connectivity to getcomposer.org"
|
||
return 250
|
||
fi
|
||
|
||
$STD php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer || {
|
||
msg_error "Failed to install Composer"
|
||
rm -f /tmp/composer-setup.php
|
||
return 150
|
||
}
|
||
rm -f /tmp/composer-setup.php
|
||
|
||
if [[ ! -x "$COMPOSER_BIN" ]]; then
|
||
msg_error "Composer installation failed"
|
||
return 127
|
||
fi
|
||
|
||
chmod +x "$COMPOSER_BIN"
|
||
$STD "$COMPOSER_BIN" self-update --no-interaction || {
|
||
msg_warn "Composer self-update failed after fresh install"
|
||
}
|
||
|
||
local FINAL_VERSION
|
||
FINAL_VERSION=$("$COMPOSER_BIN" --version 2>/dev/null | awk '{print $3}')
|
||
cache_installed_version "composer" "$FINAL_VERSION"
|
||
msg_ok "Setup Composer"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Docker Engine Installation and Management (All-In-One)
|
||
#
|
||
# Description:
|
||
# - By default uses distro repository (docker.io) for stability
|
||
# - Optionally uses official Docker repository for latest features
|
||
# - Detects and migrates old Docker installations
|
||
# - Optional: Installs/Updates Portainer CE
|
||
# - Updates running containers interactively
|
||
# - Cleans up legacy repository files
|
||
#
|
||
# Usage:
|
||
# setup_docker # Uses distro package (recommended)
|
||
# USE_DOCKER_REPO=true setup_docker # Uses official Docker repo
|
||
# DOCKER_PORTAINER="true" setup_docker
|
||
# DOCKER_LOG_DRIVER="json-file" setup_docker
|
||
#
|
||
# Variables:
|
||
# USE_DOCKER_REPO - Set to "true" to use official Docker repository
|
||
# (default: false, uses distro docker.io package)
|
||
# DOCKER_PORTAINER - Install Portainer CE (optional, "true" to enable)
|
||
# DOCKER_LOG_DRIVER - Log driver (optional, default: "journald")
|
||
# DOCKER_SKIP_UPDATES - Skip container update check (optional, "true" to skip)
|
||
#
|
||
# Features:
|
||
# - Uses stable distro packages by default
|
||
# - Migrates from get.docker.com to repository-based installation
|
||
# - Updates Docker Engine if newer version available
|
||
# - Interactive container update with multi-select
|
||
# - Portainer installation and update support
|
||
# ------------------------------------------------------------------------------
|
||
setup_docker() {
|
||
local docker_installed=false
|
||
local portainer_installed=false
|
||
local USE_DOCKER_REPO="${USE_DOCKER_REPO:-false}"
|
||
|
||
# Check if Docker is already installed
|
||
if command -v docker &>/dev/null; then
|
||
docker_installed=true
|
||
DOCKER_CURRENT_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1)
|
||
msg_info "Docker $DOCKER_CURRENT_VERSION detected"
|
||
fi
|
||
|
||
# Check if Portainer is running
|
||
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^portainer$'; then
|
||
portainer_installed=true
|
||
msg_info "Portainer container detected"
|
||
fi
|
||
|
||
# Scenario 1: Use distro repository (default, most stable)
|
||
if [[ "$USE_DOCKER_REPO" != "true" && "$USE_DOCKER_REPO" != "TRUE" && "$USE_DOCKER_REPO" != "1" ]]; then
|
||
|
||
# Install or upgrade Docker from distro repo
|
||
if [ "$docker_installed" = true ]; then
|
||
msg_info "Checking for Docker updates (distro package)"
|
||
ensure_apt_working || return 100
|
||
upgrade_packages_with_retry "docker.io" "docker-compose" || true
|
||
DOCKER_CURRENT_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1)
|
||
msg_ok "Docker is up-to-date ($DOCKER_CURRENT_VERSION)"
|
||
else
|
||
msg_info "Installing Docker (distro package)"
|
||
ensure_apt_working || return 100
|
||
|
||
# Install docker.io and docker-compose from distro
|
||
if ! install_packages_with_retry "docker.io"; then
|
||
msg_error "Failed to install docker.io from distro repository"
|
||
return 100
|
||
fi
|
||
# docker-compose is optional
|
||
$STD apt install -y docker-compose 2>/dev/null ||
|
||
msg_warn "Optional docker-compose not available from distro repository — use 'docker compose' plugin instead if needed"
|
||
|
||
DOCKER_CURRENT_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1)
|
||
msg_ok "Installed Docker $DOCKER_CURRENT_VERSION (distro package)"
|
||
fi
|
||
|
||
# Configure daemon.json
|
||
local log_driver="${DOCKER_LOG_DRIVER:-journald}"
|
||
mkdir -p /etc/docker
|
||
if [ ! -f /etc/docker/daemon.json ]; then
|
||
cat <<EOF >/etc/docker/daemon.json
|
||
{
|
||
"log-driver": "$log_driver"
|
||
}
|
||
EOF
|
||
fi
|
||
|
||
# Enable and start Docker
|
||
systemctl enable -q --now docker
|
||
|
||
# Continue to Portainer section below
|
||
else
|
||
# Scenario 2: Use official Docker repository (USE_DOCKER_REPO=true)
|
||
|
||
# Cleanup old repository configurations
|
||
if [ -f /etc/apt/sources.list.d/docker.list ]; then
|
||
msg_info "Migrating from old Docker repository format"
|
||
rm -f /etc/apt/sources.list.d/docker.list
|
||
rm -f /etc/apt/keyrings/docker.asc
|
||
fi
|
||
|
||
# Setup/Update Docker repository
|
||
msg_info "Setting up Docker Repository"
|
||
setup_deb822_repo \
|
||
"docker" \
|
||
"https://download.docker.com/linux/$(get_os_info id)/gpg" \
|
||
"https://download.docker.com/linux/$(get_os_info id)" \
|
||
"$(get_os_info codename)" \
|
||
"stable" \
|
||
"$(dpkg --print-architecture)"
|
||
|
||
# Install or upgrade Docker
|
||
if [ "$docker_installed" = true ]; then
|
||
msg_info "Checking for Docker updates"
|
||
DOCKER_LATEST_VERSION=$(apt-cache policy docker-ce | grep Candidate | awk '{print $2}' 2>/dev/null | cut -d':' -f2 | cut -d'-' -f1 || echo '')
|
||
|
||
if [ "$DOCKER_CURRENT_VERSION" != "$DOCKER_LATEST_VERSION" ]; then
|
||
msg_info "Updating Docker $DOCKER_CURRENT_VERSION → $DOCKER_LATEST_VERSION"
|
||
$STD apt install -y --only-upgrade \
|
||
docker-ce \
|
||
docker-ce-cli \
|
||
containerd.io \
|
||
docker-buildx-plugin \
|
||
docker-compose-plugin || {
|
||
msg_error "Failed to update Docker packages"
|
||
return 100
|
||
}
|
||
msg_ok "Updated Docker to $DOCKER_LATEST_VERSION"
|
||
else
|
||
msg_ok "Docker is up-to-date ($DOCKER_CURRENT_VERSION)"
|
||
fi
|
||
else
|
||
msg_info "Installing Docker"
|
||
$STD apt install -y \
|
||
docker-ce \
|
||
docker-ce-cli \
|
||
containerd.io \
|
||
docker-buildx-plugin \
|
||
docker-compose-plugin || {
|
||
msg_error "Failed to install Docker packages"
|
||
return 100
|
||
}
|
||
|
||
DOCKER_CURRENT_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1)
|
||
msg_ok "Installed Docker $DOCKER_CURRENT_VERSION"
|
||
fi
|
||
|
||
# Configure daemon.json
|
||
local log_driver="${DOCKER_LOG_DRIVER:-journald}"
|
||
mkdir -p /etc/docker
|
||
if [ ! -f /etc/docker/daemon.json ]; then
|
||
cat <<EOF >/etc/docker/daemon.json
|
||
{
|
||
"log-driver": "$log_driver"
|
||
}
|
||
EOF
|
||
fi
|
||
|
||
# Enable and start Docker
|
||
systemctl enable -q --now docker
|
||
fi
|
||
|
||
# Portainer Management (common for both modes)
|
||
if [[ "${DOCKER_PORTAINER:-}" == "true" ]]; then
|
||
if [ "$portainer_installed" = true ]; then
|
||
msg_info "Checking for Portainer updates"
|
||
PORTAINER_CURRENT=$(docker inspect portainer --format='{{.Config.Image}}' 2>/dev/null | cut -d':' -f2)
|
||
PORTAINER_LATEST=$(curl -fsSL https://registry.hub.docker.com/v2/repositories/portainer/portainer-ce/tags?page_size=100 | grep -oP '"name":"\K[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | tr -d '"')
|
||
|
||
if [ "$PORTAINER_CURRENT" != "$PORTAINER_LATEST" ]; then
|
||
read -r -p "${TAB3}Update Portainer $PORTAINER_CURRENT → $PORTAINER_LATEST? <y/N> " prompt
|
||
if [[ ${prompt,,} =~ ^(y|yes)$ ]]; then
|
||
msg_info "Updating Portainer"
|
||
docker stop portainer
|
||
docker rm portainer
|
||
docker pull portainer/portainer-ce:latest
|
||
docker run -d \
|
||
-p 9000:9000 \
|
||
-p 9443:9443 \
|
||
--name=portainer \
|
||
--restart=always \
|
||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||
-v portainer_data:/data \
|
||
portainer/portainer-ce:latest
|
||
msg_ok "Updated Portainer to $PORTAINER_LATEST"
|
||
fi
|
||
else
|
||
msg_ok "Portainer is up-to-date ($PORTAINER_CURRENT)"
|
||
fi
|
||
else
|
||
msg_info "Installing Portainer"
|
||
docker volume create portainer_data
|
||
docker run -d \
|
||
-p 9000:9000 \
|
||
-p 9443:9443 \
|
||
--name=portainer \
|
||
--restart=always \
|
||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||
-v portainer_data:/data \
|
||
portainer/portainer-ce:latest
|
||
|
||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||
msg_ok "Installed Portainer (http://${LOCAL_IP}:9000)"
|
||
fi
|
||
fi
|
||
|
||
# Interactive Container Update Check
|
||
if [[ "${DOCKER_SKIP_UPDATES:-}" != "true" ]] && [ "$docker_installed" = true ]; then
|
||
msg_info "Checking for container updates"
|
||
|
||
# Get list of running containers with update status
|
||
local containers_with_updates=()
|
||
local container_info=()
|
||
local index=1
|
||
|
||
while IFS= read -r container; do
|
||
local name=$(echo "$container" | awk '{print $1}')
|
||
local image=$(echo "$container" | awk '{print $2}')
|
||
local current_digest=$(docker inspect "$name" --format='{{.Image}}' 2>/dev/null | cut -d':' -f2 | cut -c1-12)
|
||
|
||
# Pull latest image digest
|
||
docker pull "$image" >/dev/null 2>&1
|
||
local latest_digest=$(docker inspect "$image" --format='{{.Id}}' 2>/dev/null | cut -d':' -f2 | cut -c1-12)
|
||
|
||
if [ "$current_digest" != "$latest_digest" ]; then
|
||
containers_with_updates+=("$name")
|
||
container_info+=("${index}) ${name} (${image})")
|
||
((index++))
|
||
fi
|
||
done < <(docker ps --format '{{.Names}} {{.Image}}')
|
||
|
||
if [ ${#containers_with_updates[@]} -gt 0 ]; then
|
||
echo ""
|
||
echo "${TAB3}Container updates available:"
|
||
for info in "${container_info[@]}"; do
|
||
echo "${TAB3} $info"
|
||
done
|
||
echo ""
|
||
read -r -p "${TAB3}Select containers to update (e.g., 1,3,5 or 'all' or 'none'): " selection
|
||
|
||
if [[ ${selection,,} == "all" ]]; then
|
||
for container in "${containers_with_updates[@]}"; do
|
||
msg_info "Updating container: $container"
|
||
docker stop "$container"
|
||
docker rm "$container"
|
||
# Note: This requires the original docker run command - best to recreate via compose
|
||
msg_ok "Stopped and removed $container (please recreate with updated image)"
|
||
done
|
||
elif [[ ${selection,,} != "none" ]]; then
|
||
IFS=',' read -ra SELECTED <<<"$selection"
|
||
for num in "${SELECTED[@]}"; do
|
||
num=$(echo "$num" | xargs) # trim whitespace
|
||
if [[ "$num" =~ ^[0-9]+$ ]] && [ "$num" -ge 1 ] && [ "$num" -le "${#containers_with_updates[@]}" ]; then
|
||
container="${containers_with_updates[$((num - 1))]}"
|
||
msg_info "Updating container: $container"
|
||
docker stop "$container"
|
||
docker rm "$container"
|
||
msg_ok "Stopped and removed $container (please recreate with updated image)"
|
||
fi
|
||
done
|
||
fi
|
||
else
|
||
msg_ok "All containers are up-to-date"
|
||
fi
|
||
fi
|
||
|
||
msg_ok "Docker setup completed"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs FFmpeg from source or prebuilt binary (Debian/Ubuntu only).
|
||
#
|
||
# Description:
|
||
# - Downloads and builds FFmpeg from GitHub (https://github.com/FFmpeg/FFmpeg)
|
||
# - Supports specific version override via FFMPEG_VERSION (e.g. n7.1.1)
|
||
# - Supports build profile via FFMPEG_TYPE:
|
||
# - minimal : x264, vpx, mp3 only
|
||
# - medium : adds subtitles, fonts, opus, vorbis
|
||
# - full : adds dav1d, svt-av1, zlib, numa
|
||
# - binary : downloads static build (johnvansickle.com)
|
||
# - Defaults to latest stable version and full feature set
|
||
#
|
||
# Notes:
|
||
# - Requires: curl, jq, build-essential, and matching codec libraries
|
||
# - Result is installed to /usr/local/bin/ffmpeg
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_ffmpeg() {
|
||
local TMP_DIR=$(mktemp -d)
|
||
local GITHUB_REPO="FFmpeg/FFmpeg"
|
||
local VERSION="${FFMPEG_VERSION:-latest}"
|
||
local TYPE="${FFMPEG_TYPE:-full}"
|
||
local BIN_PATH="/usr/local/bin/ffmpeg"
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
if command -v ffmpeg &>/dev/null; then
|
||
INSTALLED_VERSION=$(ffmpeg -version 2>/dev/null | head -n1 | awk '{print $3}')
|
||
fi
|
||
|
||
msg_info "Setup FFmpeg ${VERSION} ($TYPE)"
|
||
|
||
# Binary fallback mode
|
||
if [[ "$TYPE" == "binary" ]]; then
|
||
local ffmpeg_arch
|
||
case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
|
||
arm64) ffmpeg_arch="arm64" ;;
|
||
*) ffmpeg_arch="amd64" ;;
|
||
esac
|
||
if ! CURL_TIMEOUT=300 curl_with_retry "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ffmpeg_arch}-static.tar.xz" "$TMP_DIR/ffmpeg.tar.xz"; then
|
||
msg_error "Failed to download FFmpeg binary"
|
||
msg_error "Hint: Check connectivity to johnvansickle.com/ffmpeg (static builds, may be slow — large file)"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
fi
|
||
tar -xf "$TMP_DIR/ffmpeg.tar.xz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract FFmpeg binary"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
local EXTRACTED_DIR
|
||
EXTRACTED_DIR=$(find "$TMP_DIR" -maxdepth 1 -type d -name "ffmpeg-*")
|
||
cp "$EXTRACTED_DIR/ffmpeg" "$BIN_PATH"
|
||
cp "$EXTRACTED_DIR/ffprobe" /usr/local/bin/ffprobe
|
||
chmod +x "$BIN_PATH" /usr/local/bin/ffprobe
|
||
local FINAL_VERSION=$($BIN_PATH -version 2>/dev/null | head -n1 | awk '{print $3}')
|
||
rm -rf "$TMP_DIR"
|
||
cache_installed_version "ffmpeg" "$FINAL_VERSION"
|
||
ensure_usr_local_bin_persist
|
||
[[ -n "$INSTALLED_VERSION" ]] && msg_ok "Upgrade FFmpeg $INSTALLED_VERSION → $FINAL_VERSION" || msg_ok "Setup FFmpeg $FINAL_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
ensure_dependencies jq
|
||
|
||
# Auto-detect latest stable version if none specified
|
||
if [[ "$VERSION" == "latest" || -z "$VERSION" ]]; then
|
||
local ffmpeg_tags
|
||
ffmpeg_tags=$(curl -fsSL --max-time 15 "https://api.github.com/repos/${GITHUB_REPO}/tags" 2>/dev/null || echo "")
|
||
|
||
if [[ -z "$ffmpeg_tags" ]]; then
|
||
msg_warn "Could not fetch FFmpeg versions from GitHub, trying binary fallback"
|
||
VERSION="" # Will trigger binary fallback below
|
||
else
|
||
VERSION=$(echo "$ffmpeg_tags" | jq -r '.[].name' 2>/dev/null |
|
||
grep -E '^n[0-9]+\.[0-9]+\.[0-9]+$' |
|
||
sort -V | tail -n1 || echo "")
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$VERSION" ]]; then
|
||
msg_info "Could not determine FFmpeg source version, using pre-built binary"
|
||
VERSION="" # Will use binary fallback
|
||
fi
|
||
|
||
# Dependency selection
|
||
local DEPS=(build-essential yasm nasm pkg-config)
|
||
case "$TYPE" in
|
||
minimal)
|
||
DEPS+=(libx264-dev libvpx-dev libmp3lame-dev)
|
||
;;
|
||
medium)
|
||
DEPS+=(libx264-dev libvpx-dev libmp3lame-dev libfreetype6-dev libass-dev libopus-dev libvorbis-dev)
|
||
;;
|
||
full)
|
||
DEPS+=(
|
||
libx264-dev libx265-dev libvpx-dev libmp3lame-dev
|
||
libfreetype6-dev libass-dev libopus-dev libvorbis-dev
|
||
libdav1d-dev zlib1g-dev libnuma-dev
|
||
libva-dev libdrm-dev
|
||
)
|
||
if apt-cache show libsvtav1enc-dev &>/dev/null; then
|
||
DEPS+=(libsvtav1enc-dev)
|
||
elif apt-cache show libsvtav1-dev &>/dev/null; then
|
||
DEPS+=(libsvtav1-dev)
|
||
fi
|
||
;;
|
||
*)
|
||
msg_error "Invalid FFMPEG_TYPE: $TYPE"
|
||
rm -rf "$TMP_DIR"
|
||
return 65
|
||
;;
|
||
esac
|
||
|
||
ensure_dependencies "${DEPS[@]}"
|
||
|
||
# Try to download source if VERSION is set
|
||
if [[ -n "$VERSION" ]]; then
|
||
if ! CURL_TIMEOUT=300 curl_with_retry "https://github.com/${GITHUB_REPO}/archive/refs/tags/${VERSION}.tar.gz" "$TMP_DIR/ffmpeg.tar.gz"; then
|
||
msg_warn "Failed to download FFmpeg source ${VERSION}, falling back to pre-built binary"
|
||
VERSION=""
|
||
fi
|
||
fi
|
||
|
||
# If no source download (either VERSION empty or download failed), use binary
|
||
if [[ -z "$VERSION" ]]; then
|
||
msg_info "Setup FFmpeg from pre-built binary"
|
||
local ffmpeg_arch detected_arch
|
||
detected_arch="$(dpkg --print-architecture 2>/dev/null || true)"
|
||
if [[ -z "$detected_arch" ]]; then
|
||
detected_arch="$(uname -m 2>/dev/null || true)"
|
||
fi
|
||
case "$detected_arch" in
|
||
arm64 | aarch64) ffmpeg_arch="arm64" ;;
|
||
amd64 | x86_64) ffmpeg_arch="amd64" ;;
|
||
*) ffmpeg_arch="amd64" ;;
|
||
esac
|
||
if ! CURL_TIMEOUT=300 curl_with_retry "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ffmpeg_arch}-static.tar.xz" "$TMP_DIR/ffmpeg.tar.xz"; then
|
||
msg_error "Failed to download FFmpeg pre-built binary"
|
||
msg_error "Hint: Check connectivity to johnvansickle.com/ffmpeg (large file, may timeout on slow connections)"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
fi
|
||
|
||
tar -xJf "$TMP_DIR/ffmpeg.tar.xz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract FFmpeg binary archive"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
if ! cp "$TMP_DIR/ffmpeg-"*/ffmpeg /usr/local/bin/ffmpeg 2>/dev/null; then
|
||
msg_error "Failed to install FFmpeg binary"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
fi
|
||
|
||
cache_installed_version "ffmpeg" "static"
|
||
rm -rf "$TMP_DIR"
|
||
msg_ok "Setup FFmpeg from pre-built binary"
|
||
return 0
|
||
fi
|
||
|
||
tar -xzf "$TMP_DIR/ffmpeg.tar.gz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract FFmpeg source"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
cd "$TMP_DIR/FFmpeg-"* || {
|
||
msg_error "Source extraction failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
local args=(
|
||
--enable-gpl
|
||
--enable-shared
|
||
--enable-nonfree
|
||
--disable-static
|
||
--enable-libx264
|
||
--enable-libvpx
|
||
--enable-libmp3lame
|
||
)
|
||
|
||
if [[ "$TYPE" != "minimal" ]]; then
|
||
args+=(--enable-libfreetype --enable-libass --enable-libopus --enable-libvorbis)
|
||
fi
|
||
|
||
if [[ "$TYPE" == "full" ]]; then
|
||
args+=(--enable-libx265 --enable-libdav1d --enable-zlib)
|
||
args+=(--enable-vaapi --enable-libdrm)
|
||
fi
|
||
|
||
if [[ ${#args[@]} -eq 0 ]]; then
|
||
msg_error "FFmpeg configure args array is empty"
|
||
rm -rf "$TMP_DIR"
|
||
return 65
|
||
fi
|
||
|
||
$STD ./configure "${args[@]}" || {
|
||
msg_error "FFmpeg configure failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD make -j"$(nproc)" || {
|
||
msg_error "FFmpeg compilation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD make install || {
|
||
msg_error "FFmpeg installation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
echo "/usr/local/lib" >/etc/ld.so.conf.d/ffmpeg.conf
|
||
$STD ldconfig
|
||
|
||
ldconfig -p 2>/dev/null | grep libavdevice >/dev/null || {
|
||
msg_error "libavdevice not registered with dynamic linker"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
|
||
if ! command -v ffmpeg &>/dev/null; then
|
||
msg_error "FFmpeg installation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
fi
|
||
|
||
local FINAL_VERSION
|
||
FINAL_VERSION=$(ffmpeg -version 2>/dev/null | head -n1 | awk '{print $3}')
|
||
rm -rf "$TMP_DIR"
|
||
cache_installed_version "ffmpeg" "$FINAL_VERSION"
|
||
ensure_usr_local_bin_persist
|
||
[[ -n "$INSTALLED_VERSION" ]] && msg_ok "Upgrade FFmpeg $INSTALLED_VERSION → $FINAL_VERSION" || msg_ok "Setup FFmpeg $FINAL_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs Go (Golang) from official tarball.
|
||
#
|
||
# Description:
|
||
# - Determines system architecture
|
||
# - Downloads latest version if GO_VERSION not set
|
||
#
|
||
# Variables:
|
||
# GO_VERSION - Version to install (e.g. 1.22.2 or latest)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_go() {
|
||
local ARCH
|
||
case "$(uname -m)" in
|
||
x86_64) ARCH="amd64" ;;
|
||
aarch64) ARCH="arm64" ;;
|
||
*)
|
||
msg_error "Unsupported architecture: $(uname -m)"
|
||
return 236
|
||
;;
|
||
esac
|
||
|
||
# Resolve "latest" version
|
||
local GO_VERSION="${GO_VERSION:-latest}"
|
||
if [[ "$GO_VERSION" == "latest" ]]; then
|
||
local go_version_tmp
|
||
go_version_tmp=$(curl_with_retry "https://go.dev/VERSION?m=text" "-" 2>/dev/null | head -n1 | sed 's/^go//') || true
|
||
if [[ -z "$go_version_tmp" ]]; then
|
||
msg_error "Could not determine latest Go version"
|
||
return 250
|
||
fi
|
||
GO_VERSION="$go_version_tmp"
|
||
fi
|
||
|
||
local GO_BIN="/usr/local/bin/go"
|
||
local GO_INSTALL_DIR="/usr/local/go"
|
||
|
||
# Get currently installed version
|
||
local CURRENT_VERSION=""
|
||
if [[ -x "$GO_BIN" ]]; then
|
||
CURRENT_VERSION=$("$GO_BIN" version 2>/dev/null | awk '{print $3}' | sed 's/go//')
|
||
fi
|
||
|
||
# Scenario 1: Already at target version
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" == "$GO_VERSION" ]]; then
|
||
cache_installed_version "go" "$GO_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version or not installed
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "$GO_VERSION" ]]; then
|
||
msg_info "Upgrade Go from $CURRENT_VERSION to $GO_VERSION"
|
||
remove_old_tool_version "go"
|
||
else
|
||
msg_info "Setup Go $GO_VERSION"
|
||
fi
|
||
|
||
local TARBALL="go${GO_VERSION}.linux-${ARCH}.tar.gz"
|
||
local URL="https://go.dev/dl/${TARBALL}"
|
||
local TMP_TAR=$(mktemp)
|
||
|
||
if ! CURL_TIMEOUT=300 curl_with_retry "$URL" "$TMP_TAR"; then
|
||
msg_error "Failed to download Go $GO_VERSION"
|
||
msg_error "Hint: Check connectivity to go.dev/dl — URL: $URL"
|
||
rm -f "$TMP_TAR"
|
||
return 250
|
||
fi
|
||
|
||
$STD tar -C /usr/local -xzf "$TMP_TAR" || {
|
||
msg_error "Failed to extract Go tarball"
|
||
rm -f "$TMP_TAR"
|
||
return 251
|
||
}
|
||
|
||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||
rm -f "$TMP_TAR"
|
||
|
||
cache_installed_version "go" "$GO_VERSION"
|
||
ensure_usr_local_bin_persist
|
||
msg_ok "Setup Go $GO_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or updates Ghostscript (gs) from source.
|
||
#
|
||
# Description:
|
||
# - Fetches latest release
|
||
# - Builds and installs system-wide
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_gs() {
|
||
local TMP_DIR=$(mktemp -d)
|
||
local CURRENT_VERSION=$(gs --version 2>/dev/null || echo "0")
|
||
|
||
ensure_dependencies jq
|
||
|
||
local LATEST_VERSION
|
||
# Tags are "gs10.05.1" format — fetch without stripping "v", then strip "gs"
|
||
LATEST_VERSION=$(get_latest_github_release "ArtifexSoftware/ghostpdl-downloads" "false") || {
|
||
msg_warn "Cannot fetch latest Ghostscript version from GitHub API"
|
||
if command -v gs &>/dev/null; then
|
||
gs --version | head -n1
|
||
cache_installed_version "ghostscript" "$CURRENT_VERSION"
|
||
return 0
|
||
fi
|
||
msg_error "Cannot determine Ghostscript version and no existing installation found"
|
||
return 250
|
||
}
|
||
LATEST_VERSION="${LATEST_VERSION#gs}"
|
||
local LATEST_VERSION_DOTTED=$(echo "$LATEST_VERSION" | sed 's/\([0-9]\{2\}\)\([0-9]\{2\}\)\([0-9]\{1\}\)/\1.\2.\3/')
|
||
|
||
if [[ -z "$LATEST_VERSION" || -z "$LATEST_VERSION_DOTTED" ]]; then
|
||
msg_warn "Could not determine latest Ghostscript version from GitHub - checking system"
|
||
# Fallback: try to use system version or return error
|
||
if [[ "$CURRENT_VERSION" == "0" ]]; then
|
||
msg_error "Ghostscript not installed and cannot determine latest version"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
fi
|
||
rm -rf "$TMP_DIR"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 1: Already at latest version
|
||
if [[ -n "$LATEST_VERSION_DOTTED" ]] && dpkg --compare-versions "$CURRENT_VERSION" ge "$LATEST_VERSION_DOTTED" 2>/dev/null; then
|
||
cache_installed_version "ghostscript" "$LATEST_VERSION_DOTTED"
|
||
rm -rf "$TMP_DIR"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: New install or upgrade
|
||
if [[ "$CURRENT_VERSION" != "0" && "$CURRENT_VERSION" != "$LATEST_VERSION_DOTTED" ]]; then
|
||
msg_info "Upgrade Ghostscript from $CURRENT_VERSION to $LATEST_VERSION_DOTTED"
|
||
else
|
||
msg_info "Setup Ghostscript $LATEST_VERSION_DOTTED"
|
||
fi
|
||
|
||
if ! CURL_TIMEOUT=180 curl_with_retry "https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs${LATEST_VERSION}/ghostscript-${LATEST_VERSION_DOTTED}.tar.gz" "$TMP_DIR/ghostscript.tar.gz"; then
|
||
msg_error "Failed to download Ghostscript"
|
||
msg_error "Hint: Check connectivity to github.com/ArtifexSoftware — may be a GitHub rate-limit, set GITHUB_TOKEN"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
fi
|
||
|
||
if ! tar -xzf "$TMP_DIR/ghostscript.tar.gz" -C "$TMP_DIR"; then
|
||
msg_error "Failed to extract Ghostscript archive"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
fi
|
||
|
||
# Verify directory exists before cd
|
||
if [[ ! -d "$TMP_DIR/ghostscript-${LATEST_VERSION_DOTTED}" ]]; then
|
||
msg_error "Ghostscript source directory not found: $TMP_DIR/ghostscript-${LATEST_VERSION_DOTTED}"
|
||
rm -rf "$TMP_DIR"
|
||
return 252
|
||
fi
|
||
|
||
cd "$TMP_DIR/ghostscript-${LATEST_VERSION_DOTTED}" || {
|
||
msg_error "Failed to enter Ghostscript source directory"
|
||
rm -rf "$TMP_DIR"
|
||
return 252
|
||
}
|
||
|
||
ensure_dependencies build-essential libpng-dev zlib1g-dev
|
||
|
||
$STD ./configure || {
|
||
msg_error "Ghostscript configure failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD make -j"$(nproc)" || {
|
||
msg_error "Ghostscript compilation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD make install || {
|
||
msg_error "Ghostscript installation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
|
||
hash -r
|
||
if [[ ! -x "$(command -v gs)" ]]; then
|
||
if [[ -x /usr/local/bin/gs ]]; then
|
||
ln -sf /usr/local/bin/gs /usr/bin/gs
|
||
fi
|
||
fi
|
||
|
||
rm -rf "$TMP_DIR"
|
||
cache_installed_version "ghostscript" "$LATEST_VERSION_DOTTED"
|
||
ensure_usr_local_bin_persist
|
||
msg_ok "Setup Ghostscript $LATEST_VERSION_DOTTED"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Sets up Hardware Acceleration on debian or ubuntu.
|
||
#
|
||
# Description:
|
||
# - Detects all available GPUs (Intel, AMD, NVIDIA)
|
||
# - Allows user to select which GPU(s) to configure (with 60s timeout)
|
||
# - Installs the correct libraries and packages for each GPU type
|
||
# - Supports: Debian 11/12/13, Ubuntu 22.04/24.04
|
||
# - Intel: Legacy (Gen 6-8), Modern (Gen 9+), Arc
|
||
# - AMD: Discrete GPUs, APUs, ROCm compute
|
||
# - NVIDIA: Version-matched drivers from CUDA repository
|
||
#
|
||
# Notes:
|
||
# - Some Intel packages are fetched from GitHub due to missing Debian packages
|
||
# - NVIDIA requires matching host driver version
|
||
# ------------------------------------------------------------------------------
|
||
setup_hwaccel() {
|
||
local service_user="${1:-}"
|
||
|
||
# Check if user explicitly disabled GPU in advanced settings
|
||
# ENABLE_GPU is exported from build.func
|
||
if [[ "${ENABLE_GPU:-no}" == "no" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
# Check if GPU passthrough is enabled (device nodes must exist)
|
||
if [[ ! -d /dev/dri && ! -e /dev/nvidia0 && ! -e /dev/kfd ]]; then
|
||
msg_warn "No GPU passthrough detected (/dev/dri, /dev/nvidia*, /dev/kfd not found) - skipping hardware acceleration setup"
|
||
return 0
|
||
fi
|
||
|
||
msg_info "Setup Hardware Acceleration"
|
||
|
||
# Install pciutils if needed
|
||
if ! command -v lspci &>/dev/null; then
|
||
$STD apt -y update || {
|
||
msg_warn "Failed to update package list"
|
||
return 0
|
||
}
|
||
$STD apt -y install pciutils || {
|
||
msg_warn "Failed to install pciutils"
|
||
return 0
|
||
}
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# GPU Detection - Build list of all available GPUs with details
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
local -a GPU_LIST=()
|
||
local -a GPU_TYPES=()
|
||
local -a GPU_NAMES=()
|
||
local gpu_count=0
|
||
|
||
# Get all GPU entries from lspci
|
||
while IFS= read -r line; do
|
||
[[ -z "$line" ]] && continue
|
||
local pci_addr gpu_name gpu_type=""
|
||
|
||
pci_addr=$(echo "$line" | awk '{print $1}')
|
||
gpu_name=$(echo "$line" | sed 's/^[^ ]* [^:]*: //')
|
||
|
||
# Determine GPU type
|
||
# Note: Use -w (word boundary) for ATI to avoid matching "CorporATIon"
|
||
if echo "$gpu_name" | grep -qi 'Intel'; then
|
||
gpu_type="INTEL"
|
||
# Subtype detection for Intel
|
||
# Order matters: Check Arc first, then Gen9+ (UHD/Iris/HD 5xx-6xx), then Legacy (HD 2xxx-5xxx)
|
||
# HD Graphics 530/630 = Gen 9 (Skylake/Kaby Lake) - 3 digits
|
||
# HD Graphics 4600/5500 = Gen 7-8 (Haswell/Broadwell) - 4 digits starting with 2-5
|
||
if echo "$gpu_name" | grep -qiE 'Arc|DG[12]'; then
|
||
gpu_type="INTEL_ARC"
|
||
elif echo "$gpu_name" | grep -qiE 'UHD|Iris|HD Graphics [5-6][0-9]{2}[^0-9]|HD Graphics [5-6][0-9]{2}$'; then
|
||
# HD Graphics 5xx/6xx (3 digits) = Gen 9+ (Skylake onwards)
|
||
gpu_type="INTEL_GEN9+"
|
||
elif echo "$gpu_name" | grep -qiE 'HD Graphics [2-5][0-9]{3}'; then
|
||
# HD Graphics 2xxx-5xxx (4 digits) = Gen 6-8 Legacy
|
||
gpu_type="INTEL_LEGACY"
|
||
fi
|
||
elif echo "$gpu_name" | grep -qiwE 'AMD|ATI|Radeon|Advanced Micro Devices'; then
|
||
gpu_type="AMD"
|
||
elif echo "$gpu_name" | grep -qi 'NVIDIA'; then
|
||
gpu_type="NVIDIA"
|
||
fi
|
||
|
||
if [[ -n "$gpu_type" ]]; then
|
||
GPU_LIST+=("$pci_addr")
|
||
GPU_TYPES+=("$gpu_type")
|
||
GPU_NAMES+=("$gpu_name")
|
||
((gpu_count++)) || true
|
||
fi
|
||
done < <(lspci 2>/dev/null | grep -Ei 'vga|3d|display')
|
||
|
||
# Check for AMD APU via CPU vendor if no discrete GPU found
|
||
local cpu_vendor
|
||
cpu_vendor=$(lscpu 2>/dev/null | grep -i 'Vendor ID' | awk '{print $3}' 2>/dev/null || echo "")
|
||
|
||
if [[ $gpu_count -eq 0 ]]; then
|
||
if [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
|
||
GPU_LIST+=("integrated")
|
||
GPU_TYPES+=("AMD_APU")
|
||
GPU_NAMES+=("AMD APU (Integrated Graphics)")
|
||
((gpu_count++)) || true
|
||
else
|
||
msg_warn "No GPU detected - skipping hardware acceleration setup"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# GPU Selection - Let user choose which GPU(s) to configure
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
local -a SELECTED_INDICES=()
|
||
local install_nvidia_drivers="yes"
|
||
|
||
if [[ $gpu_count -eq 1 ]]; then
|
||
# Single GPU - auto-select
|
||
SELECTED_INDICES=(0)
|
||
msg_ok "Detected GPU: ${GPU_NAMES[0]} (${GPU_TYPES[0]})"
|
||
else
|
||
# Multiple GPUs - show selection menu
|
||
echo ""
|
||
msg_custom "⚠" "${YW}" "Multiple GPUs detected:"
|
||
echo ""
|
||
for i in "${!GPU_LIST[@]}"; do
|
||
local type_display="${GPU_TYPES[$i]}"
|
||
case "${GPU_TYPES[$i]}" in
|
||
INTEL_ARC) type_display="Intel Arc" ;;
|
||
INTEL_GEN9+) type_display="Intel Gen9+" ;;
|
||
INTEL_LEGACY) type_display="Intel Legacy" ;;
|
||
INTEL) type_display="Intel" ;;
|
||
AMD) type_display="AMD" ;;
|
||
AMD_APU) type_display="AMD APU" ;;
|
||
NVIDIA) type_display="NVIDIA" ;;
|
||
esac
|
||
printf " %d) [%s] %s\n" "$((i + 1))" "$type_display" "${GPU_NAMES[$i]}"
|
||
done
|
||
printf " A) Configure ALL GPUs\n"
|
||
echo ""
|
||
|
||
# Read with 60 second timeout
|
||
local selection=""
|
||
echo -n "Select GPU(s) to configure (1-${gpu_count}, A=all) [timeout 60s, default=all]: "
|
||
if read -r -t 60 selection; then
|
||
selection="${selection^^}" # uppercase
|
||
else
|
||
echo ""
|
||
msg_info "Timeout - configuring all GPUs automatically"
|
||
selection="A"
|
||
fi
|
||
|
||
# Parse selection
|
||
if [[ "$selection" == "A" || -z "$selection" ]]; then
|
||
# Select all
|
||
for i in "${!GPU_LIST[@]}"; do
|
||
SELECTED_INDICES+=("$i")
|
||
done
|
||
elif [[ "$selection" =~ ^[0-9,]+$ ]]; then
|
||
# Parse comma-separated numbers
|
||
IFS=',' read -ra nums <<<"$selection"
|
||
for num in "${nums[@]}"; do
|
||
num=$(echo "$num" | tr -d ' ')
|
||
if [[ "$num" =~ ^[0-9]+$ ]] && ((num >= 1 && num <= gpu_count)); then
|
||
SELECTED_INDICES+=("$((num - 1))")
|
||
fi
|
||
done
|
||
else
|
||
# Invalid - default to all
|
||
msg_warn "Invalid selection - configuring all GPUs"
|
||
for i in "${!GPU_LIST[@]}"; do
|
||
SELECTED_INDICES+=("$i")
|
||
done
|
||
fi
|
||
fi
|
||
|
||
# Ask whether to install NVIDIA drivers in the container
|
||
local nvidia_selected="no"
|
||
for idx in "${SELECTED_INDICES[@]}"; do
|
||
if [[ "${GPU_TYPES[$idx]}" == "NVIDIA" ]]; then
|
||
nvidia_selected="yes"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ "$nvidia_selected" == "yes" ]]; then
|
||
if [[ -n "${INSTALL_NVIDIA_DRIVERS:-}" ]]; then
|
||
install_nvidia_drivers="${INSTALL_NVIDIA_DRIVERS}"
|
||
else
|
||
echo ""
|
||
msg_custom "🎮" "${GN}" "NVIDIA GPU passthrough detected"
|
||
local nvidia_reply=""
|
||
read -r -t 60 -p "${TAB3}⚙️ Install NVIDIA driver libraries in the container? [Y/n] (auto-yes in 60s): " nvidia_reply || nvidia_reply=""
|
||
case "${nvidia_reply,,}" in
|
||
n | no) install_nvidia_drivers="no" ;;
|
||
*) install_nvidia_drivers="yes" ;;
|
||
esac
|
||
fi
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# OS Detection
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
local os_id os_codename os_version
|
||
os_id=$(get_os_info id)
|
||
os_codename=$(get_os_info codename)
|
||
os_version=$(get_os_info version)
|
||
[[ -z "$os_id" ]] && os_id="debian"
|
||
|
||
local in_ct="${CTTYPE:-0}"
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# Process Selected GPUs
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
for idx in "${SELECTED_INDICES[@]}"; do
|
||
local gpu_type="${GPU_TYPES[$idx]}"
|
||
local gpu_name="${GPU_NAMES[$idx]}"
|
||
|
||
msg_info "Configuring: ${gpu_name}"
|
||
|
||
case "$gpu_type" in
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Intel Arc GPUs (DG1, DG2, Arc A-series)
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
INTEL_ARC)
|
||
_setup_intel_arc "$os_id" "$os_codename"
|
||
;;
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Intel Gen 9+ (Skylake 2015+: UHD, Iris, HD 6xx+)
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
INTEL_GEN9+ | INTEL)
|
||
_setup_intel_modern "$os_id" "$os_codename"
|
||
;;
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Intel Legacy (Gen 6-8: HD 2000-5999, Sandy Bridge to Broadwell)
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
INTEL_LEGACY)
|
||
_setup_intel_legacy "$os_id" "$os_codename"
|
||
;;
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# AMD Discrete GPUs
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
AMD)
|
||
_setup_amd_gpu "$os_id" "$os_codename"
|
||
;;
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# AMD APU (Integrated Graphics)
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
AMD_APU)
|
||
_setup_amd_apu "$os_id" "$os_codename"
|
||
;;
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# NVIDIA GPUs
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
NVIDIA)
|
||
if [[ "$install_nvidia_drivers" == "yes" ]]; then
|
||
_setup_nvidia_gpu "$os_id" "$os_codename" "$os_version"
|
||
else
|
||
msg_warn "Skipping NVIDIA driver installation (user opted to install manually)"
|
||
fi
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# Device Permissions
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
_setup_gpu_permissions "$in_ct" "$service_user"
|
||
|
||
cache_installed_version "hwaccel" "1.0"
|
||
msg_ok "Setup Hardware Acceleration"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Resolve the IGC tag that the latest compute-runtime was built against.
|
||
# Must be called AFTER a fetch_and_deploy_gh_release for intel/compute-runtime
|
||
# so that /tmp/gh_rel.json contains the compute-runtime release metadata.
|
||
# Sets the variable named by $1 (default: igc_tag) to the discovered tag.
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_resolve_igc_tag() {
|
||
local -n _out_ref="${1:-igc_tag}"
|
||
_out_ref="latest"
|
||
if [[ -f /tmp/gh_rel.json ]]; then
|
||
local _body _parsed
|
||
_body=$(jq -r '.body // empty' /tmp/gh_rel.json 2>/dev/null) || return 0
|
||
_parsed=$(grep -oP 'intel-graphics-compiler/releases/tag/\K[^\s\)]+' <<<"$_body" | head -1)
|
||
[[ -n "$_parsed" ]] && _out_ref="$_parsed"
|
||
fi
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Intel Arc GPU Setup
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_intel_arc() {
|
||
local os_id="$1" os_codename="$2"
|
||
|
||
msg_info "Installing Intel Arc GPU drivers"
|
||
|
||
if [[ "$os_id" == "ubuntu" ]]; then
|
||
# Ubuntu 22.04+ has Arc support in HWE kernel
|
||
$STD apt -y install \
|
||
intel-media-va-driver-non-free \
|
||
intel-opencl-icd \
|
||
libmfx-gen1.2 \
|
||
vainfo \
|
||
intel-gpu-tools 2>/dev/null || msg_warn "Some Intel Arc packages failed"
|
||
|
||
elif [[ "$os_id" == "debian" ]]; then
|
||
# Add non-free repos
|
||
_add_debian_nonfree "$os_codename"
|
||
|
||
# For Trixie/Sid: Fetch latest drivers from GitHub (Debian repo packages may be too old or missing)
|
||
# For Bookworm: Use repo packages (GitHub latest requires libstdc++6 >= 13.1, unavailable on Bookworm)
|
||
if [[ "$os_codename" == "trixie" || "$os_codename" == "sid" ]]; then
|
||
msg_info "Fetching Intel compute-runtime from GitHub for Arc support"
|
||
|
||
# Fetch a compute-runtime package first so /tmp/gh_rel.json is populated,
|
||
# then resolve the matching IGC tag from the release notes.
|
||
# libigdgmm - bundled in compute-runtime releases
|
||
fetch_and_deploy_gh_release "libigdgmm12" "intel/compute-runtime" "binary" "latest" "" "libigdgmm12_*_amd64.deb" || true
|
||
|
||
local igc_tag
|
||
_resolve_igc_tag igc_tag
|
||
|
||
# Intel Graphics Compiler – pinned to the version compute-runtime expects
|
||
fetch_and_deploy_gh_release "intel-igc-core" "intel/intel-graphics-compiler" "binary" "$igc_tag" "" "intel-igc-core-2_*_amd64.deb" || true
|
||
fetch_and_deploy_gh_release "intel-igc-opencl" "intel/intel-graphics-compiler" "binary" "$igc_tag" "" "intel-igc-opencl-2_*_amd64.deb" || true
|
||
|
||
# Compute Runtime (depends on IGC and gmmlib)
|
||
fetch_and_deploy_gh_release "intel-opencl-icd" "intel/compute-runtime" "binary" "latest" "" "intel-opencl-icd_*_amd64.deb" || true
|
||
fetch_and_deploy_gh_release "intel-level-zero-gpu" "intel/compute-runtime" "binary" "latest" "" "libze-intel-gpu1_*_amd64.deb" || true
|
||
fi
|
||
|
||
$STD apt -y install \
|
||
intel-media-va-driver-non-free \
|
||
ocl-icd-libopencl1 \
|
||
libvpl2 \
|
||
libmfx-gen1.2 \
|
||
vainfo \
|
||
intel-gpu-tools 2>/dev/null || msg_warn "Some Intel Arc packages failed"
|
||
|
||
# Bookworm has compatible versions of these packages in repos
|
||
[[ "$os_codename" == "bookworm" ]] && $STD apt -y install intel-opencl-icd libigdgmm12 2>/dev/null || true
|
||
fi
|
||
|
||
msg_ok "Intel Arc GPU configured"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Intel Modern GPU Setup (Gen 9+)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_intel_modern() {
|
||
local os_id="$1" os_codename="$2"
|
||
|
||
msg_info "Installing Intel Gen 9+ GPU drivers"
|
||
|
||
if [[ "$os_id" == "ubuntu" ]]; then
|
||
$STD apt -y install \
|
||
va-driver-all \
|
||
intel-media-va-driver \
|
||
ocl-icd-libopencl1 \
|
||
vainfo \
|
||
intel-gpu-tools 2>/dev/null || msg_warn "Some Intel packages failed"
|
||
|
||
# Try non-free driver for better codec support
|
||
$STD apt -y install intel-media-va-driver-non-free 2>/dev/null || true
|
||
$STD apt -y install intel-opencl-icd 2>/dev/null || true
|
||
$STD apt -y install libmfx-gen1.2 2>/dev/null || true
|
||
|
||
elif [[ "$os_id" == "debian" ]]; then
|
||
_add_debian_nonfree "$os_codename"
|
||
|
||
# For Trixie/Sid: Fetch from GitHub (Debian packages too old or missing)
|
||
if [[ "$os_codename" == "trixie" || "$os_codename" == "sid" ]]; then
|
||
msg_info "Fetching Intel compute-runtime from GitHub"
|
||
|
||
# Fetch a compute-runtime package first so /tmp/gh_rel.json is populated,
|
||
# then resolve the matching IGC tag from the release notes.
|
||
# libigdgmm first (bundled in compute-runtime releases)
|
||
fetch_and_deploy_gh_release "libigdgmm12" "intel/compute-runtime" "binary" "latest" "" "libigdgmm12_*_amd64.deb" || true
|
||
|
||
local igc_tag
|
||
_resolve_igc_tag igc_tag
|
||
|
||
# Intel Graphics Compiler – pinned to the version compute-runtime expects
|
||
fetch_and_deploy_gh_release "intel-igc-core" "intel/intel-graphics-compiler" "binary" "$igc_tag" "" "intel-igc-core-2_*_amd64.deb" || true
|
||
fetch_and_deploy_gh_release "intel-igc-opencl" "intel/intel-graphics-compiler" "binary" "$igc_tag" "" "intel-igc-opencl-2_*_amd64.deb" || true
|
||
|
||
# Compute Runtime
|
||
fetch_and_deploy_gh_release "intel-opencl-icd" "intel/compute-runtime" "binary" "latest" "" "intel-opencl-icd_*_amd64.deb" || true
|
||
fi
|
||
|
||
$STD apt -y install \
|
||
intel-media-va-driver-non-free \
|
||
ocl-icd-libopencl1 \
|
||
vainfo \
|
||
libmfx-gen1.2 \
|
||
intel-gpu-tools 2>/dev/null || msg_warn "Some Intel packages failed"
|
||
|
||
# Bookworm has intel-opencl-icd in repos (compatible version)
|
||
[[ "$os_codename" == "bookworm" ]] && $STD apt -y install intel-opencl-icd libigdgmm12 2>/dev/null || true
|
||
fi
|
||
|
||
msg_ok "Intel Gen 9+ GPU configured"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Intel Legacy GPU Setup (Gen 6-8)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_intel_legacy() {
|
||
local os_id="$1" os_codename="$2"
|
||
|
||
msg_info "Installing Intel Legacy GPU drivers (Gen 6-8)"
|
||
|
||
# Legacy GPUs use i965 driver - stable repo packages only
|
||
$STD apt -y install \
|
||
va-driver-all \
|
||
i965-va-driver \
|
||
mesa-va-drivers \
|
||
ocl-icd-libopencl1 \
|
||
vainfo \
|
||
intel-gpu-tools 2>/dev/null || msg_warn "Some Intel legacy packages failed"
|
||
|
||
# beignet provides OpenCL for older Intel GPUs (Sandy Bridge to Broadwell)
|
||
# Note: beignet-opencl-icd was removed in Debian 12+ and Ubuntu 22.04+
|
||
# Check if package is available before attempting installation
|
||
if apt-cache show beignet-opencl-icd &>/dev/null; then
|
||
$STD apt -y install beignet-opencl-icd 2>/dev/null || msg_warn "beignet-opencl-icd installation failed (optional)"
|
||
else
|
||
msg_warn "beignet-opencl-icd not available - OpenCL support for legacy Intel GPU limited"
|
||
msg_warn "Note: Hardware video encoding/decoding (VA-API) still works without OpenCL"
|
||
fi
|
||
|
||
msg_ok "Intel Legacy GPU configured"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# AMD Discrete GPU Setup
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_amd_gpu() {
|
||
local os_id="$1" os_codename="$2"
|
||
|
||
msg_info "Installing AMD GPU drivers"
|
||
|
||
# Core Mesa drivers
|
||
$STD apt -y install \
|
||
mesa-va-drivers \
|
||
mesa-vdpau-drivers \
|
||
mesa-opencl-icd \
|
||
ocl-icd-libopencl1 \
|
||
libdrm-amdgpu1 \
|
||
vainfo \
|
||
clinfo 2>/dev/null || msg_warn "Some AMD packages failed"
|
||
|
||
# Firmware for AMD GPUs
|
||
if [[ "$os_id" == "debian" ]]; then
|
||
_add_debian_nonfree_firmware "$os_codename"
|
||
$STD apt -y install firmware-amd-graphics 2>/dev/null || msg_warn "AMD firmware not available"
|
||
fi
|
||
# Ubuntu includes AMD firmware in linux-firmware by default
|
||
|
||
# ROCm compute stack (OpenCL + HIP)
|
||
_setup_rocm "$os_id" "$os_codename"
|
||
|
||
msg_ok "AMD GPU configured"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# AMD APU Setup (Integrated Graphics)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_amd_apu() {
|
||
local os_id="$1" os_codename="$2"
|
||
|
||
msg_info "Installing AMD APU drivers"
|
||
|
||
$STD apt -y install \
|
||
mesa-va-drivers \
|
||
mesa-vdpau-drivers \
|
||
mesa-opencl-icd \
|
||
ocl-icd-libopencl1 \
|
||
vainfo 2>/dev/null || msg_warn "Some AMD APU packages failed"
|
||
|
||
if [[ "$os_id" == "debian" ]]; then
|
||
_add_debian_nonfree_firmware "$os_codename"
|
||
$STD apt -y install firmware-amd-graphics 2>/dev/null || true
|
||
fi
|
||
|
||
msg_ok "AMD APU configured"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# AMD ROCm Compute Setup
|
||
# Adds ROCm repository and installs the ROCm compute stack for AMD GPUs/APUs.
|
||
# Provides: OpenCL, HIP, rocm-smi, rocminfo
|
||
# Supported: Debian 12/13, Ubuntu 22.04/24.04 (amd64 only)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_rocm() {
|
||
local os_id="$1" os_codename="$2"
|
||
|
||
# Only amd64 is supported
|
||
if [[ "$(dpkg --print-architecture 2>/dev/null)" != "amd64" ]]; then
|
||
msg_warn "ROCm is only available for amd64 — skipping"
|
||
return 0
|
||
fi
|
||
|
||
local ROCM_VERSION="7.2"
|
||
local ROCM_REPO_CODENAME
|
||
|
||
# Map OS codename to ROCm repository codename (Ubuntu-based repos)
|
||
case "${os_id}-${os_codename}" in
|
||
debian-bookworm) ROCM_REPO_CODENAME="jammy" ;;
|
||
debian-trixie | debian-sid) ROCM_REPO_CODENAME="noble" ;;
|
||
ubuntu-jammy) ROCM_REPO_CODENAME="jammy" ;;
|
||
ubuntu-noble) ROCM_REPO_CODENAME="noble" ;;
|
||
*)
|
||
msg_warn "ROCm not supported on ${os_id} ${os_codename} — skipping"
|
||
return 0
|
||
;;
|
||
esac
|
||
|
||
msg_info "Installing ROCm ${ROCM_VERSION} compute stack"
|
||
|
||
# ROCm main repository (userspace compute libs)
|
||
setup_deb822_repo \
|
||
"rocm" \
|
||
"https://repo.radeon.com/rocm/rocm.gpg.key" \
|
||
"https://repo.radeon.com/rocm/apt/${ROCM_VERSION}" \
|
||
"${ROCM_REPO_CODENAME}" \
|
||
"main" \
|
||
"amd64" || {
|
||
msg_warn "Failed to add ROCm repository — skipping ROCm"
|
||
return 0
|
||
}
|
||
|
||
# Note: The amdgpu/latest/ubuntu repo (kernel driver packages) is intentionally
|
||
# omitted — kernel drivers are managed by the Proxmox host, not the LXC container.
|
||
# Only the ROCm userspace compute stack is needed inside the container.
|
||
|
||
# Pin ROCm packages to prefer radeon repo
|
||
cat <<EOF >/etc/apt/preferences.d/rocm-pin-600
|
||
Package: *
|
||
Pin: release o=repo.radeon.com
|
||
Pin-Priority: 600
|
||
EOF
|
||
|
||
# apt update with retry — repo.radeon.com CDN can be mid-sync (transient size mismatches).
|
||
# Run with ERR trap disabled so a transient failure does not abort the entire install.
|
||
local _apt_ok=0
|
||
for _attempt in 1 2 3; do
|
||
if (
|
||
set +e
|
||
apt-get update -qq 2>&1
|
||
exit $?
|
||
) 2>/dev/null; then
|
||
_apt_ok=1
|
||
break
|
||
fi
|
||
msg_warn "apt update failed (attempt ${_attempt}/3) — AMD repo may be temporarily unavailable, retrying in 30s…"
|
||
sleep 30
|
||
done
|
||
if [[ $_apt_ok -eq 0 ]]; then
|
||
msg_warn "apt update still failing after 3 attempts — skipping ROCm install"
|
||
return 0
|
||
fi
|
||
|
||
# Install only runtime packages — full 'rocm' meta-package includes 15GB+ dev tools
|
||
$STD apt install -y rocm-opencl-runtime rocm-hip-runtime rocm-smi-lib 2>/dev/null || {
|
||
msg_warn "ROCm runtime install failed — trying minimal set"
|
||
$STD apt install -y rocm-opencl-runtime rocm-smi-lib 2>/dev/null || msg_warn "ROCm minimal install also failed"
|
||
}
|
||
|
||
# Group membership for GPU access
|
||
usermod -aG render,video root 2>/dev/null || true
|
||
|
||
# Environment (PATH + LD_LIBRARY_PATH)
|
||
if [[ -d /opt/rocm ]]; then
|
||
cat <<'ENVEOF' >/etc/profile.d/rocm.sh
|
||
export PATH="$PATH:/opt/rocm/bin"
|
||
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}/opt/rocm/lib"
|
||
ENVEOF
|
||
chmod +x /etc/profile.d/rocm.sh
|
||
# Also make available for current session / systemd services
|
||
echo "/opt/rocm/lib" >/etc/ld.so.conf.d/rocm.conf
|
||
ldconfig 2>/dev/null || true
|
||
fi
|
||
|
||
if [[ -x /opt/rocm/bin/rocminfo ]]; then
|
||
msg_ok "ROCm ${ROCM_VERSION} installed"
|
||
else
|
||
msg_warn "ROCm installed but rocminfo not found — GPU may not be available in container"
|
||
fi
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# NVIDIA GPU Setup
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_nvidia_gpu() {
|
||
local os_id="$1" os_codename="$2" os_version="$3"
|
||
|
||
msg_info "Installing NVIDIA GPU drivers"
|
||
|
||
# Prevent interactive dialogs (e.g., "Mismatching nvidia kernel module" whiptail)
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export NEEDRESTART_MODE=a
|
||
|
||
# Detect host driver version (passed through via /proc)
|
||
# Format varies by driver type:
|
||
# Proprietary: "NVRM version: NVIDIA UNIX x86_64 Kernel Module 550.54.14 Thu..."
|
||
# Open: "NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 590.48.01 Release..."
|
||
# Use regex to extract version number (###.##.## or ###.## pattern)
|
||
local nvidia_host_version=""
|
||
if [[ -f /proc/driver/nvidia/version ]]; then
|
||
nvidia_host_version=$(grep -oP '\d{3,}\.\d+(\.\d+)?' /proc/driver/nvidia/version 2>/dev/null | head -1 || true)
|
||
fi
|
||
|
||
if [[ -z "$nvidia_host_version" ]]; then
|
||
msg_warn "NVIDIA host driver version not found in /proc/driver/nvidia/version"
|
||
msg_warn "Ensure NVIDIA drivers are installed on host and GPU passthrough is enabled"
|
||
$STD apt-get -y install va-driver-all vainfo 2>/dev/null || true
|
||
return 0
|
||
fi
|
||
|
||
msg_info "Host NVIDIA driver version: ${nvidia_host_version}"
|
||
|
||
if [[ "$os_id" == "debian" ]]; then
|
||
# Enable non-free components
|
||
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||
if ! grep -q "non-free" /etc/apt/sources.list.d/debian.sources 2>/dev/null; then
|
||
sed -i -E 's/Components: (.*)$/Components: \1 contrib non-free non-free-firmware/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true
|
||
fi
|
||
fi
|
||
$STD apt-get -y update 2>/dev/null || msg_warn "apt update failed - continuing anyway"
|
||
|
||
# For Debian 13 Trixie/Sid: Use Debian's own nvidia packages first (better compatibility)
|
||
# NVIDIA's CUDA repo targets Debian 12 and may not have amd64 packages for Trixie
|
||
if [[ "$os_codename" == "trixie" || "$os_codename" == "sid" ]]; then
|
||
msg_info "Debian ${os_codename}: Using Debian's NVIDIA packages"
|
||
|
||
# Extract major version for flexible matching (580.126.09 -> 580)
|
||
local nvidia_major_version="${nvidia_host_version%%.*}"
|
||
|
||
# Check what versions are actually available
|
||
local available_version=""
|
||
available_version=$(apt-cache madison libcuda1 2>/dev/null | awk '{print $3}' | grep -E "^${nvidia_major_version}\." | head -1 || true)
|
||
|
||
if [[ -n "$available_version" ]]; then
|
||
msg_info "Found available NVIDIA version: ${available_version}"
|
||
local nvidia_pkgs="libcuda1=${available_version} libnvcuvid1=${available_version} libnvidia-encode1=${available_version} libnvidia-ml1=${available_version}"
|
||
if $STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends $nvidia_pkgs 2>/dev/null; then
|
||
msg_ok "Installed NVIDIA libraries (${available_version})"
|
||
else
|
||
msg_warn "Failed to install NVIDIA ${available_version} - trying unversioned"
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends libcuda1 libnvcuvid1 libnvidia-encode1 libnvidia-ml1 2>/dev/null || true
|
||
fi
|
||
else
|
||
# No matching major version - try latest available or unversioned
|
||
msg_warn "No NVIDIA packages for version ${nvidia_major_version}.x found in repos"
|
||
available_version=$(apt-cache madison libcuda1 2>/dev/null | awk '{print $3}' | head -1 || true)
|
||
if [[ -n "$available_version" ]]; then
|
||
msg_info "Trying latest available: ${available_version} (may cause version mismatch)"
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends \
|
||
libcuda1="${available_version}" libnvcuvid1="${available_version}" \
|
||
libnvidia-encode1="${available_version}" libnvidia-ml1="${available_version}" 2>/dev/null ||
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends \
|
||
libcuda1 libnvcuvid1 libnvidia-encode1 libnvidia-ml1 2>/dev/null ||
|
||
msg_warn "NVIDIA library installation failed - GPU compute may not work"
|
||
else
|
||
msg_warn "No NVIDIA packages available in Debian repos - GPU support disabled"
|
||
fi
|
||
fi
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends nvidia-smi 2>/dev/null || true
|
||
|
||
else
|
||
# Debian 11/12: Use NVIDIA CUDA repository for version matching
|
||
local cuda_repo="debian12"
|
||
case "$os_codename" in
|
||
bullseye) cuda_repo="debian11" ;;
|
||
bookworm) cuda_repo="debian12" ;;
|
||
esac
|
||
|
||
# Add NVIDIA CUDA repository
|
||
if [[ ! -f /usr/share/keyrings/cuda-archive-keyring.gpg ]]; then
|
||
msg_info "Adding NVIDIA CUDA repository (${cuda_repo})"
|
||
local cuda_keyring
|
||
cuda_keyring="$(mktemp)"
|
||
if curl -fsSL -o "$cuda_keyring" "https://developer.download.nvidia.com/compute/cuda/repos/${cuda_repo}/x86_64/cuda-keyring_1.1-1_all.deb" 2>/dev/null; then
|
||
$STD dpkg -i "$cuda_keyring" 2>/dev/null || true
|
||
else
|
||
msg_warn "Failed to download NVIDIA CUDA keyring"
|
||
fi
|
||
rm -f "$cuda_keyring"
|
||
fi
|
||
|
||
# Pin NVIDIA repo for version matching
|
||
cat <<'NVIDIA_PIN' >/etc/apt/preferences.d/nvidia-cuda-pin
|
||
Package: *
|
||
Pin: origin developer.download.nvidia.com
|
||
Pin-Priority: 1001
|
||
NVIDIA_PIN
|
||
|
||
$STD apt-get -y update 2>/dev/null || msg_warn "apt update failed - continuing anyway"
|
||
|
||
# Extract major version for flexible matching (580.126.09 -> 580)
|
||
local nvidia_major_version="${nvidia_host_version%%.*}"
|
||
|
||
# Check what versions are actually available in CUDA repo
|
||
local available_version=""
|
||
available_version=$(apt-cache madison libcuda1 2>/dev/null | awk '{print $3}' | grep -E "^${nvidia_major_version}\." | head -1 || true)
|
||
|
||
if [[ -n "$available_version" ]]; then
|
||
msg_info "Installing NVIDIA libraries (version ${available_version})"
|
||
local nvidia_pkgs="libcuda1=${available_version} libnvcuvid1=${available_version} libnvidia-encode1=${available_version} libnvidia-ml1=${available_version}"
|
||
if $STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends $nvidia_pkgs 2>/dev/null; then
|
||
msg_ok "Installed version-matched NVIDIA libraries"
|
||
else
|
||
msg_warn "Version-pinned install failed - trying unpinned"
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends libcuda1 libnvcuvid1 libnvidia-encode1 libnvidia-ml1 2>/dev/null ||
|
||
msg_warn "NVIDIA library installation failed"
|
||
fi
|
||
else
|
||
msg_warn "No NVIDIA packages for version ${nvidia_major_version}.x in CUDA repo (host: ${nvidia_host_version})"
|
||
# Try latest available version
|
||
available_version=$(apt-cache madison libcuda1 2>/dev/null | awk '{print $3}' | head -1 || true)
|
||
if [[ -n "$available_version" ]]; then
|
||
msg_info "Trying latest available: ${available_version} (version mismatch warning)"
|
||
if $STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends \
|
||
libcuda1="${available_version}" libnvcuvid1="${available_version}" \
|
||
libnvidia-encode1="${available_version}" libnvidia-ml1="${available_version}" 2>/dev/null; then
|
||
msg_ok "Installed NVIDIA libraries (${available_version}) - version differs from host"
|
||
else
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends libcuda1 libnvcuvid1 libnvidia-encode1 libnvidia-ml1 2>/dev/null ||
|
||
msg_warn "NVIDIA library installation failed"
|
||
fi
|
||
else
|
||
msg_warn "No NVIDIA packages available in CUDA repo - GPU support disabled"
|
||
fi
|
||
fi
|
||
|
||
$STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends nvidia-smi 2>/dev/null || true
|
||
fi
|
||
|
||
elif [[ "$os_id" == "ubuntu" ]]; then
|
||
# Ubuntu versioning
|
||
local ubuntu_cuda_repo=""
|
||
case "$os_version" in
|
||
22.04) ubuntu_cuda_repo="ubuntu2204" ;;
|
||
24.04) ubuntu_cuda_repo="ubuntu2404" ;;
|
||
*) ubuntu_cuda_repo="ubuntu2204" ;; # Fallback
|
||
esac
|
||
|
||
# Add NVIDIA CUDA repository for Ubuntu
|
||
if [[ ! -f /usr/share/keyrings/cuda-archive-keyring.gpg ]]; then
|
||
msg_info "Adding NVIDIA CUDA repository (${ubuntu_cuda_repo})"
|
||
local cuda_keyring
|
||
cuda_keyring="$(mktemp)"
|
||
if curl -fsSL -o "$cuda_keyring" "https://developer.download.nvidia.com/compute/cuda/repos/${ubuntu_cuda_repo}/x86_64/cuda-keyring_1.1-1_all.deb" 2>/dev/null; then
|
||
$STD dpkg -i "$cuda_keyring" 2>/dev/null || true
|
||
else
|
||
msg_warn "Failed to download NVIDIA CUDA keyring"
|
||
fi
|
||
rm -f "$cuda_keyring"
|
||
fi
|
||
|
||
$STD apt-get -y update 2>/dev/null || msg_warn "apt update failed - continuing anyway"
|
||
|
||
# Extract major version for flexible matching
|
||
local nvidia_major_version="${nvidia_host_version%%.*}"
|
||
|
||
# Check what versions are available
|
||
local available_version=""
|
||
available_version=$(apt-cache madison libcuda1 2>/dev/null | awk '{print $3}' | grep -E "^${nvidia_major_version}\." | head -1 || true)
|
||
|
||
if [[ -n "$available_version" ]]; then
|
||
msg_info "Installing NVIDIA libraries (version ${available_version})"
|
||
local nvidia_pkgs="libcuda1=${available_version} libnvcuvid1=${available_version} libnvidia-encode1=${available_version} libnvidia-ml1=${available_version}"
|
||
if $STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends $nvidia_pkgs 2>/dev/null; then
|
||
msg_ok "Installed version-matched NVIDIA libraries"
|
||
else
|
||
# Fallback to Ubuntu repo packages with versioned nvidia-utils
|
||
msg_warn "CUDA repo install failed - trying Ubuntu native packages (nvidia-utils-${nvidia_major_version})"
|
||
if $STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends \
|
||
libnvidia-decode-${nvidia_major_version} libnvidia-encode-${nvidia_major_version} nvidia-utils-${nvidia_major_version} 2>/dev/null; then
|
||
msg_ok "Installed Ubuntu NVIDIA packages (${nvidia_major_version})"
|
||
else
|
||
msg_warn "NVIDIA driver installation failed - please install manually: apt install nvidia-utils-${nvidia_major_version}"
|
||
fi
|
||
fi
|
||
else
|
||
msg_warn "No NVIDIA packages for version ${nvidia_major_version}.x in CUDA repo"
|
||
# Fallback to Ubuntu repo packages with versioned nvidia-utils
|
||
msg_info "Trying Ubuntu native packages (nvidia-utils-${nvidia_major_version})"
|
||
if $STD apt-get -y -o Dpkg::Options::="--force-confold" install --no-install-recommends \
|
||
libnvidia-decode-${nvidia_major_version} libnvidia-encode-${nvidia_major_version} nvidia-utils-${nvidia_major_version} 2>/dev/null; then
|
||
msg_ok "Installed Ubuntu NVIDIA packages (${nvidia_major_version})"
|
||
else
|
||
msg_warn "NVIDIA driver installation failed - please install manually: apt install nvidia-utils-${nvidia_major_version}"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# VA-API for hybrid setups (Intel + NVIDIA)
|
||
$STD apt-get -y install va-driver-all vainfo 2>/dev/null || true
|
||
|
||
# Fix GLX alternatives: nvidia-alternative diverts mesa libs but in LXC
|
||
# containers the nvidia GLX libs are typically missing, leaving libGL.so.1
|
||
# pointing nowhere. Fall back to mesa if nvidia GLX dir is empty/missing.
|
||
if command -v update-glx &>/dev/null; then
|
||
local nvidia_glx_dir="/usr/lib/nvidia"
|
||
if [[ ! -f "${nvidia_glx_dir}/libGL.so.1" ]] && [[ -d /usr/lib/mesa-diverted ]]; then
|
||
msg_info "NVIDIA GLX libs missing in container - falling back to mesa"
|
||
$STD update-glx --set glx /usr/lib/mesa-diverted 2>/dev/null || true
|
||
ldconfig 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
msg_ok "NVIDIA GPU configured"
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Helper: Add Debian non-free repositories
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_add_debian_nonfree() {
|
||
local os_codename="$1"
|
||
|
||
[[ -f /etc/apt/sources.list.d/non-free.sources ]] && return 0
|
||
|
||
case "$os_codename" in
|
||
bullseye)
|
||
cat <<'EOF' >/etc/apt/sources.list.d/non-free.sources
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: bullseye bullseye-updates
|
||
Components: non-free
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
EOF
|
||
;;
|
||
bookworm)
|
||
cat <<'EOF' >/etc/apt/sources.list.d/non-free.sources
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: bookworm bookworm-updates
|
||
Components: non-free non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
EOF
|
||
;;
|
||
trixie | sid)
|
||
cat <<'EOF' >/etc/apt/sources.list.d/non-free.sources
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: trixie trixie-updates
|
||
Components: non-free non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian-security
|
||
Suites: trixie-security
|
||
Components: non-free non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
EOF
|
||
;;
|
||
esac
|
||
$STD apt -y update
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Helper: Add Debian non-free-firmware repository
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_add_debian_nonfree_firmware() {
|
||
local os_codename="$1"
|
||
|
||
[[ -f /etc/apt/sources.list.d/non-free-firmware.sources ]] && return 0
|
||
|
||
case "$os_codename" in
|
||
bullseye)
|
||
# Debian 11 uses 'non-free' component (no separate non-free-firmware)
|
||
cat <<'EOF' >/etc/apt/sources.list.d/non-free-firmware.sources
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: bullseye bullseye-updates
|
||
Components: non-free
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian-security
|
||
Suites: bullseye-security
|
||
Components: non-free
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
EOF
|
||
;;
|
||
bookworm)
|
||
cat <<'EOF' >/etc/apt/sources.list.d/non-free-firmware.sources
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: bookworm bookworm-updates
|
||
Components: non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian-security
|
||
Suites: bookworm-security
|
||
Components: non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
EOF
|
||
;;
|
||
trixie | sid)
|
||
cat <<'EOF' >/etc/apt/sources.list.d/non-free-firmware.sources
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: trixie trixie-updates
|
||
Components: non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian-security
|
||
Suites: trixie-security
|
||
Components: non-free-firmware
|
||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||
EOF
|
||
;;
|
||
esac
|
||
$STD apt -y update
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Helper: Setup GPU device permissions
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_setup_gpu_permissions() {
|
||
local in_ct="$1"
|
||
local service_user="${2:-}"
|
||
|
||
# /dev/dri permissions (Intel/AMD)
|
||
if [[ "$in_ct" == "0" && -d /dev/dri ]]; then
|
||
if ls /dev/dri/card* /dev/dri/renderD* &>/dev/null; then
|
||
chgrp video /dev/dri 2>/dev/null || true
|
||
chmod 755 /dev/dri 2>/dev/null || true
|
||
chmod 660 /dev/dri/* 2>/dev/null || true
|
||
$STD adduser "$(id -u -n)" video 2>/dev/null || true
|
||
$STD adduser "$(id -u -n)" render 2>/dev/null || true
|
||
|
||
# Sync GID with host
|
||
local host_video_gid host_render_gid
|
||
host_video_gid=$(getent group video | cut -d: -f3)
|
||
host_render_gid=$(getent group render | cut -d: -f3)
|
||
if [[ -n "$host_video_gid" ]]; then
|
||
sed -i "s/^video:x:[0-9]*:/video:x:$host_video_gid:/" /etc/group 2>/dev/null || true
|
||
fi
|
||
if [[ -n "$host_render_gid" ]]; then
|
||
sed -i "s/^render:x:[0-9]*:/render:x:$host_render_gid:/" /etc/group 2>/dev/null || true
|
||
fi
|
||
|
||
# Verify VA-API
|
||
if command -v vainfo &>/dev/null; then
|
||
if vainfo &>/dev/null; then
|
||
msg_info "VA-API verified and working"
|
||
else
|
||
msg_warn "vainfo test failed - check GPU passthrough"
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# /dev/nvidia* permissions (NVIDIA)
|
||
if ls /dev/nvidia* &>/dev/null 2>&1; then
|
||
msg_info "Configuring NVIDIA device permissions"
|
||
for nvidia_dev in /dev/nvidia*; do
|
||
[[ -e "$nvidia_dev" ]] && {
|
||
chgrp video "$nvidia_dev" 2>/dev/null || true
|
||
chmod 660 "$nvidia_dev" 2>/dev/null || true
|
||
}
|
||
done
|
||
if [[ -d /dev/nvidia-caps ]]; then
|
||
chmod 755 /dev/nvidia-caps 2>/dev/null || true
|
||
for caps_dev in /dev/nvidia-caps/*; do
|
||
[[ -e "$caps_dev" ]] && {
|
||
chgrp video "$caps_dev" 2>/dev/null || true
|
||
chmod 660 "$caps_dev" 2>/dev/null || true
|
||
}
|
||
done
|
||
fi
|
||
|
||
# Verify nvidia-smi
|
||
if command -v nvidia-smi &>/dev/null; then
|
||
if nvidia-smi &>/dev/null; then
|
||
msg_info "nvidia-smi verified and working"
|
||
else
|
||
msg_warn "nvidia-smi test failed - check driver version match"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# /dev/kfd permissions (AMD ROCm)
|
||
if [[ -e /dev/kfd ]]; then
|
||
chgrp render /dev/kfd 2>/dev/null || true
|
||
chmod 660 /dev/kfd 2>/dev/null || true
|
||
msg_info "AMD ROCm compute device configured"
|
||
fi
|
||
|
||
# Add service user to render and video groups for GPU hardware acceleration
|
||
if [[ -n "$service_user" ]]; then
|
||
usermod -aG render "$service_user" 2>/dev/null || true
|
||
usermod -aG video "$service_user" 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs ImageMagick 7 from source (Debian/Ubuntu only).
|
||
#
|
||
# Description:
|
||
# - Downloads the latest ImageMagick source tarball
|
||
# - Builds and installs ImageMagick to /usr/local
|
||
# - Configures dynamic linker (ldconfig)
|
||
#
|
||
# Notes:
|
||
# - Requires: build-essential, libtool, libjpeg-dev, libpng-dev, etc.
|
||
# ------------------------------------------------------------------------------
|
||
setup_imagemagick() {
|
||
local TMP_DIR=$(mktemp -d)
|
||
local BINARY_PATH="/usr/local/bin/magick"
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
if command -v magick &>/dev/null; then
|
||
INSTALLED_VERSION=$(magick -version | awk '/^Version/ {print $3}')
|
||
fi
|
||
|
||
msg_info "Setup ImageMagick"
|
||
|
||
ensure_dependencies \
|
||
build-essential \
|
||
libtool \
|
||
libjpeg-dev \
|
||
libpng-dev \
|
||
libtiff-dev \
|
||
libwebp-dev \
|
||
libheif-dev \
|
||
libde265-dev \
|
||
libopenjp2-7-dev \
|
||
libxml2-dev \
|
||
liblcms2-dev \
|
||
libfreetype6-dev \
|
||
libraw-dev \
|
||
libfftw3-dev \
|
||
liblqr-1-0-dev \
|
||
libgsl-dev \
|
||
pkg-config \
|
||
ghostscript
|
||
|
||
if ! CURL_TIMEOUT=180 curl_with_retry "https://imagemagick.org/archive/ImageMagick.tar.gz" "$TMP_DIR/ImageMagick.tar.gz"; then
|
||
msg_error "Failed to download ImageMagick"
|
||
msg_error "Hint: Check connectivity to imagemagick.org/archive"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
fi
|
||
|
||
tar -xzf "$TMP_DIR/ImageMagick.tar.gz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract ImageMagick"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
cd "$TMP_DIR"/ImageMagick-* || {
|
||
msg_error "Source extraction failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
$STD ./configure --disable-static || {
|
||
msg_error "ImageMagick configure failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD make -j"$(nproc)" || {
|
||
msg_error "ImageMagick compilation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD make install || {
|
||
msg_error "ImageMagick installation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
$STD ldconfig /usr/local/lib
|
||
|
||
if [[ ! -x "$BINARY_PATH" ]]; then
|
||
msg_error "ImageMagick installation failed"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
fi
|
||
|
||
local FINAL_VERSION
|
||
FINAL_VERSION=$("$BINARY_PATH" -version | awk '/^Version/ {print $3}')
|
||
rm -rf "$TMP_DIR"
|
||
cache_installed_version "imagemagick" "$FINAL_VERSION"
|
||
ensure_usr_local_bin_persist
|
||
|
||
if [[ -n "$INSTALLED_VERSION" ]]; then
|
||
msg_ok "Upgrade ImageMagick $INSTALLED_VERSION → $FINAL_VERSION"
|
||
else
|
||
msg_ok "Setup ImageMagick $FINAL_VERSION"
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs Temurin JDK via Adoptium APT repository.
|
||
#
|
||
# Description:
|
||
# - Removes previous JDK if version mismatch
|
||
# - Installs or upgrades to specified JAVA_VERSION
|
||
#
|
||
# Variables:
|
||
# JAVA_VERSION - Temurin JDK version to install (e.g. 17, 21)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_java() {
|
||
local JAVA_VERSION="${JAVA_VERSION:-21}"
|
||
local DISTRO_ID DISTRO_CODENAME
|
||
DISTRO_ID=$(get_os_info id)
|
||
DISTRO_CODENAME=$(get_os_info codename)
|
||
local DESIRED_PACKAGE="temurin-${JAVA_VERSION}-jdk"
|
||
|
||
# Prepare repository (cleanup + validation)
|
||
prepare_repository_setup "adoptium" || {
|
||
msg_error "Failed to prepare Adoptium repository"
|
||
return 100
|
||
}
|
||
|
||
# Add repo if needed
|
||
if [[ ! -f /etc/apt/sources.list.d/adoptium.sources ]]; then
|
||
local SUITE
|
||
SUITE=$(get_fallback_suite "$DISTRO_ID" "$DISTRO_CODENAME" "https://packages.adoptium.net/artifactory/deb")
|
||
setup_deb822_repo \
|
||
"adoptium" \
|
||
"https://packages.adoptium.net/artifactory/api/gpg/key/public" \
|
||
"https://packages.adoptium.net/artifactory/deb" \
|
||
"$SUITE" \
|
||
"main"
|
||
fi
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
INSTALLED_VERSION=$(dpkg-query -W -f '${Package}\n' 2>/dev/null | grep -oP '^temurin-\K[0-9]+(?=-jdk$)' | head -n1 || echo "")
|
||
|
||
# Scenario 1: Already at correct version
|
||
if [[ "$INSTALLED_VERSION" == "$JAVA_VERSION" ]]; then
|
||
msg_info "Update Temurin JDK $JAVA_VERSION"
|
||
ensure_apt_working || return 100
|
||
upgrade_packages_with_retry "$DESIRED_PACKAGE" || {
|
||
msg_error "Failed to update Temurin JDK"
|
||
return 100
|
||
}
|
||
cache_installed_version "temurin-jdk" "$JAVA_VERSION"
|
||
msg_ok "Update Temurin JDK $JAVA_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version - remove old and install new
|
||
if [[ -n "$INSTALLED_VERSION" ]]; then
|
||
msg_info "Upgrade Temurin JDK from $INSTALLED_VERSION to $JAVA_VERSION"
|
||
$STD apt purge -y "temurin-${INSTALLED_VERSION}-jdk" || true
|
||
else
|
||
msg_info "Setup Temurin JDK $JAVA_VERSION"
|
||
fi
|
||
|
||
ensure_apt_working || return 100
|
||
|
||
# Install with retry logic
|
||
install_packages_with_retry "$DESIRED_PACKAGE" || {
|
||
msg_error "Failed to install Temurin JDK $JAVA_VERSION"
|
||
return 100
|
||
}
|
||
|
||
cache_installed_version "temurin-jdk" "$JAVA_VERSION"
|
||
msg_ok "Setup Temurin JDK $JAVA_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs a local IP updater script using networkd-dispatcher.
|
||
#
|
||
# Description:
|
||
# - Stores current IP in /run/local-ip.env
|
||
# - Automatically runs on network changes
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_local_ip_helper() {
|
||
local BASE_DIR="/usr/local/community-scripts/ip-management"
|
||
local SCRIPT_PATH="$BASE_DIR/update_local_ip.sh"
|
||
local IP_FILE="/run/local-ip.env"
|
||
local DISPATCHER_SCRIPT="/etc/networkd-dispatcher/routable.d/10-update-local-ip.sh"
|
||
|
||
# Check if already set up
|
||
if [[ -f "$SCRIPT_PATH" && -f "$DISPATCHER_SCRIPT" ]]; then
|
||
msg_info "Update Local IP Helper"
|
||
cache_installed_version "local-ip-helper" "1.0"
|
||
msg_ok "Update Local IP Helper"
|
||
else
|
||
msg_info "Setup Local IP Helper"
|
||
fi
|
||
|
||
mkdir -p "$BASE_DIR"
|
||
|
||
# Install networkd-dispatcher if not present
|
||
if ! dpkg -s networkd-dispatcher >/dev/null 2>&1; then
|
||
ensure_dependencies networkd-dispatcher || {
|
||
msg_error "Failed to install networkd-dispatcher"
|
||
return 100
|
||
}
|
||
fi
|
||
|
||
# Write update_local_ip.sh
|
||
cat <<'EOF' >"$SCRIPT_PATH"
|
||
#!/bin/bash
|
||
set -euo pipefail
|
||
|
||
IP_FILE="/run/local-ip.env"
|
||
mkdir -p "$(dirname "$IP_FILE")"
|
||
|
||
get_current_ip() {
|
||
local ip
|
||
|
||
# Try IPv4 targets first
|
||
local ipv4_targets=("8.8.8.8" "1.1.1.1" "192.168.1.1" "10.0.0.1" "172.16.0.1" "default")
|
||
for target in "${ipv4_targets[@]}"; do
|
||
if [[ "$target" == "default" ]]; then
|
||
ip=$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}')
|
||
else
|
||
ip=$(ip route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}')
|
||
fi
|
||
if [[ -n "$ip" ]]; then
|
||
echo "$ip"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
# IPv6 fallback: Try direct interface lookup for eth0
|
||
ip=$(ip -6 addr show eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n1)
|
||
if [[ -n "$ip" && "$ip" =~ : ]]; then
|
||
echo "$ip"
|
||
return 0
|
||
fi
|
||
|
||
# IPv6 fallback: Use routing table with IPv6 targets (Google DNS, Cloudflare DNS)
|
||
local ipv6_targets=("2001:4860:4860::8888" "2606:4700:4700::1111")
|
||
for target in "${ipv6_targets[@]}"; do
|
||
ip=$(ip -6 route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}')
|
||
if [[ -n "$ip" && "$ip" =~ : ]]; then
|
||
echo "$ip"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
current_ip="$(get_current_ip)"
|
||
|
||
if [[ -z "$current_ip" ]]; then
|
||
echo "[ERROR] Could not detect local IP" >&2
|
||
exit 123
|
||
fi
|
||
|
||
if [[ -f "$IP_FILE" ]]; then
|
||
source "$IP_FILE"
|
||
[[ "$LOCAL_IP" == "$current_ip" ]] && exit 0
|
||
fi
|
||
|
||
echo "LOCAL_IP=$current_ip" > "$IP_FILE"
|
||
echo "[INFO] LOCAL_IP updated to $current_ip"
|
||
EOF
|
||
|
||
chmod +x "$SCRIPT_PATH"
|
||
|
||
# Install dispatcher hook
|
||
mkdir -p "$(dirname "$DISPATCHER_SCRIPT")"
|
||
cat <<EOF >"$DISPATCHER_SCRIPT"
|
||
#!/bin/bash
|
||
$SCRIPT_PATH
|
||
EOF
|
||
|
||
chmod +x "$DISPATCHER_SCRIPT"
|
||
systemctl enable -q --now networkd-dispatcher.service || {
|
||
msg_warn "Failed to enable networkd-dispatcher service"
|
||
}
|
||
|
||
cache_installed_version "local-ip-helper" "1.0"
|
||
msg_ok "Setup Local IP Helper"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or updates MariaDB.
|
||
#
|
||
# Description:
|
||
# - Uses Debian/Ubuntu distribution packages by default (most reliable)
|
||
# - Only uses official MariaDB repository when a specific version is requested
|
||
# - Detects current MariaDB version and replaces it if necessary
|
||
# - Preserves existing database data
|
||
#
|
||
# Variables:
|
||
# MARIADB_VERSION - MariaDB version to install (optional)
|
||
# - Not set or "latest": Uses distribution packages (recommended)
|
||
# - Specific version (e.g. "11.4", "12.2"): Uses MariaDB official repo
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_mariadb() {
|
||
local MARIADB_VERSION="${MARIADB_VERSION:-latest}"
|
||
local USE_DISTRO_PACKAGES=false
|
||
|
||
# Ensure non-interactive mode for all apt operations
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export NEEDRESTART_MODE=a
|
||
export NEEDRESTART_SUSPEND=1
|
||
|
||
# Determine installation method:
|
||
# - "latest" or empty: Use distribution packages (avoids mirror issues)
|
||
# - Specific version: Use MariaDB official repository
|
||
if [[ "$MARIADB_VERSION" == "latest" || -z "$MARIADB_VERSION" ]]; then
|
||
USE_DISTRO_PACKAGES=true
|
||
msg_info "Setup MariaDB (distribution packages)"
|
||
else
|
||
msg_info "Setup MariaDB $MARIADB_VERSION (official repository)"
|
||
fi
|
||
|
||
# Get currently installed version
|
||
local CURRENT_VERSION=""
|
||
CURRENT_VERSION=$(is_tool_installed "mariadb" 2>/dev/null) || true
|
||
|
||
# Pre-configure debconf to prevent any interactive prompts during install/upgrade
|
||
debconf-set-selections <<EOF
|
||
mariadb-server mariadb-server/feedback boolean false
|
||
mariadb-server mariadb-server/root_password password
|
||
mariadb-server mariadb-server/root_password_again password
|
||
EOF
|
||
|
||
# If specific version requested, also configure version-specific debconf
|
||
if [[ "$USE_DISTRO_PACKAGES" == "false" ]]; then
|
||
local MARIADB_MAJOR_MINOR
|
||
MARIADB_MAJOR_MINOR=$(echo "$MARIADB_VERSION" | awk -F. '{print $1"."$2}')
|
||
if [[ -n "$MARIADB_MAJOR_MINOR" ]]; then
|
||
debconf-set-selections <<EOF
|
||
mariadb-server-$MARIADB_MAJOR_MINOR mariadb-server/feedback boolean false
|
||
mariadb-server-$MARIADB_MAJOR_MINOR mariadb-server/root_password password
|
||
mariadb-server-$MARIADB_MAJOR_MINOR mariadb-server/root_password_again password
|
||
EOF
|
||
fi
|
||
fi
|
||
|
||
# ============================================================================
|
||
# DISTRIBUTION PACKAGES PATH (default, most reliable)
|
||
# ============================================================================
|
||
if [[ "$USE_DISTRO_PACKAGES" == "true" ]]; then
|
||
# Check if MariaDB was previously installed from official repo
|
||
local HAD_MARIADB_REPO=false
|
||
if [[ -f /etc/apt/sources.list.d/mariadb.sources ]] || [[ -f /etc/apt/sources.list.d/mariadb.list ]]; then
|
||
HAD_MARIADB_REPO=true
|
||
msg_info "Removing MariaDB official repository (switching to distribution packages)"
|
||
fi
|
||
|
||
# Clean up any existing MariaDB repository files to avoid conflicts
|
||
cleanup_old_repo_files "mariadb"
|
||
|
||
# If we had a repo, we need to refresh APT cache
|
||
if [[ "$HAD_MARIADB_REPO" == "true" ]]; then
|
||
$STD apt update || msg_warn "APT update had issues, continuing..."
|
||
fi
|
||
|
||
# Ensure APT is working
|
||
ensure_apt_working || return 100
|
||
|
||
# Check if installed version is from official repo and higher than distro version
|
||
# In this case, we keep the existing installation to avoid data issues
|
||
if [[ -n "$CURRENT_VERSION" ]]; then
|
||
# Get available distro version
|
||
local DISTRO_VERSION=""
|
||
DISTRO_VERSION=$(apt-cache policy mariadb-server 2>/dev/null | grep -E "Candidate:" | awk '{print $2}' | grep -oP '^\d+:\K\d+\.\d+\.\d+' || echo "")
|
||
|
||
if [[ -n "$DISTRO_VERSION" ]]; then
|
||
# Compare versions - if current is higher, keep it
|
||
local CURRENT_MAJOR DISTRO_MAJOR
|
||
CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | awk -F. '{print $1}')
|
||
DISTRO_MAJOR=$(echo "$DISTRO_VERSION" | awk -F. '{print $1}')
|
||
|
||
if [[ "$CURRENT_MAJOR" -gt "$DISTRO_MAJOR" ]]; then
|
||
msg_warn "MariaDB $CURRENT_VERSION is already installed (higher than distro $DISTRO_VERSION)"
|
||
msg_warn "Keeping existing installation to preserve data integrity"
|
||
msg_warn "To use distribution packages, manually remove MariaDB first"
|
||
_setup_mariadb_runtime_dir
|
||
cache_installed_version "mariadb" "$CURRENT_VERSION"
|
||
msg_ok "Setup MariaDB $CURRENT_VERSION (existing installation kept)"
|
||
return 0
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Install or upgrade MariaDB from distribution packages
|
||
if ! install_packages_with_retry "mariadb-server" "mariadb-client"; then
|
||
msg_error "Failed to install MariaDB packages from distribution"
|
||
return 100
|
||
fi
|
||
|
||
# Get installed version for caching
|
||
local INSTALLED_VERSION=""
|
||
INSTALLED_VERSION=$(mariadb --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -n1 || echo "distro")
|
||
|
||
# Configure runtime directory and finish
|
||
_setup_mariadb_runtime_dir
|
||
cache_installed_version "mariadb" "$INSTALLED_VERSION"
|
||
msg_ok "Setup MariaDB $INSTALLED_VERSION (distribution packages)"
|
||
return 0
|
||
fi
|
||
|
||
# ============================================================================
|
||
# OFFICIAL REPOSITORY PATH (only when specific version requested)
|
||
# ============================================================================
|
||
|
||
# First, check if there's an old/broken repository that needs cleanup
|
||
if [[ -f /etc/apt/sources.list.d/mariadb.sources ]] || [[ -f /etc/apt/sources.list.d/mariadb.list ]]; then
|
||
local OLD_REPO_VERSION=""
|
||
OLD_REPO_VERSION=$(grep -oP 'repo/\K[0-9]+\.[0-9]+(\.[0-9]+)?' /etc/apt/sources.list.d/mariadb.sources 2>/dev/null ||
|
||
grep -oP 'repo/\K[0-9]+\.[0-9]+(\.[0-9]+)?' /etc/apt/sources.list.d/mariadb.list 2>/dev/null || echo "")
|
||
|
||
# Check if old repo points to a different version
|
||
if [[ -n "$OLD_REPO_VERSION" ]] && [[ "${OLD_REPO_VERSION%.*}" != "${MARIADB_VERSION%.*}" ]]; then
|
||
msg_info "Cleaning up old MariaDB repository (was: $OLD_REPO_VERSION, requested: $MARIADB_VERSION)"
|
||
cleanup_old_repo_files "mariadb"
|
||
$STD apt update || msg_warn "APT update had issues, continuing..."
|
||
fi
|
||
fi
|
||
|
||
# Scenario 1: Already installed at target version - just update packages
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" == "$MARIADB_VERSION" ]]; then
|
||
msg_info "Update MariaDB $MARIADB_VERSION"
|
||
|
||
# Ensure APT is working
|
||
ensure_apt_working || return 100
|
||
|
||
# Check if repository needs to be refreshed
|
||
if [[ -f /etc/apt/sources.list.d/mariadb.sources ]]; then
|
||
local REPO_VERSION=""
|
||
REPO_VERSION=$(grep -oP 'repo/\K[0-9]+\.[0-9]+' /etc/apt/sources.list.d/mariadb.sources 2>/dev/null || echo "")
|
||
if [[ -n "$REPO_VERSION" && "$REPO_VERSION" != "${MARIADB_VERSION%.*}" ]]; then
|
||
msg_warn "Repository version mismatch, updating..."
|
||
manage_tool_repository "mariadb" "$MARIADB_VERSION" "http://mirror.mariadb.org/repo/$MARIADB_VERSION" \
|
||
"https://mariadb.org/mariadb_release_signing_key.asc" || {
|
||
msg_error "Failed to update MariaDB repository"
|
||
return 100
|
||
}
|
||
fi
|
||
fi
|
||
|
||
# Perform upgrade with retry logic
|
||
ensure_apt_working || return 100
|
||
upgrade_packages_with_retry "mariadb-server" "mariadb-client" || {
|
||
msg_error "Failed to upgrade MariaDB packages"
|
||
return 100
|
||
}
|
||
cache_installed_version "mariadb" "$MARIADB_VERSION"
|
||
msg_ok "Update MariaDB $MARIADB_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2b: Different version installed - clean upgrade
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "$MARIADB_VERSION" ]]; then
|
||
msg_info "Upgrade MariaDB from $CURRENT_VERSION to $MARIADB_VERSION"
|
||
remove_old_tool_version "mariadb"
|
||
fi
|
||
|
||
# Scenario 3: Fresh install or version change with specific version
|
||
# Prepare repository (cleanup + validation)
|
||
prepare_repository_setup "mariadb" || {
|
||
msg_error "Failed to prepare MariaDB repository"
|
||
return 100
|
||
}
|
||
|
||
# Install required dependencies first
|
||
local mariadb_deps=()
|
||
for dep in gawk rsync socat libdbi-perl pv; do
|
||
if apt-cache search "^${dep}$" 2>/dev/null | grep -q .; then
|
||
mariadb_deps+=("$dep")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#mariadb_deps[@]} -gt 0 ]]; then
|
||
$STD apt install -y "${mariadb_deps[@]}" 2>/dev/null || true
|
||
fi
|
||
|
||
# Setup repository
|
||
manage_tool_repository "mariadb" "$MARIADB_VERSION" "http://mirror.mariadb.org/repo/$MARIADB_VERSION" \
|
||
"https://mariadb.org/mariadb_release_signing_key.asc" || {
|
||
msg_error "Failed to setup MariaDB repository"
|
||
return 100
|
||
}
|
||
|
||
# Install packages with retry logic
|
||
if ! install_packages_with_retry "mariadb-server" "mariadb-client"; then
|
||
# Fallback: try distribution packages
|
||
msg_warn "Failed to install MariaDB $MARIADB_VERSION from official repo, falling back to distribution packages..."
|
||
cleanup_old_repo_files "mariadb"
|
||
$STD apt update || {
|
||
msg_warn "APT update also failed, continuing with cache"
|
||
}
|
||
if install_packages_with_retry "mariadb-server" "mariadb-client"; then
|
||
local FALLBACK_VERSION=""
|
||
FALLBACK_VERSION=$(mariadb --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -n1 || echo "distro")
|
||
msg_warn "Installed MariaDB $FALLBACK_VERSION from distribution instead of requested $MARIADB_VERSION"
|
||
_setup_mariadb_runtime_dir
|
||
cache_installed_version "mariadb" "$FALLBACK_VERSION"
|
||
msg_ok "Setup MariaDB $FALLBACK_VERSION (fallback to distribution packages)"
|
||
return 0
|
||
else
|
||
msg_error "Failed to install MariaDB packages (both official repo and distribution)"
|
||
return 100
|
||
fi
|
||
fi
|
||
|
||
_setup_mariadb_runtime_dir
|
||
cache_installed_version "mariadb" "$MARIADB_VERSION"
|
||
msg_ok "Setup MariaDB $MARIADB_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Helper function: Configure MariaDB runtime directory persistence
|
||
# ------------------------------------------------------------------------------
|
||
_setup_mariadb_runtime_dir() {
|
||
# Configure tmpfiles.d to ensure /run/mysqld directory is created on boot
|
||
# This fixes the issue where MariaDB fails to start after container reboot
|
||
|
||
# Create tmpfiles.d configuration with error handling
|
||
if ! printf '# Ensure /run/mysqld directory exists with correct permissions for MariaDB\nd /run/mysqld 0755 mysql mysql -\n' >/etc/tmpfiles.d/mariadb.conf; then
|
||
msg_warn "Failed to create /etc/tmpfiles.d/mariadb.conf - runtime directory may not persist on reboot"
|
||
fi
|
||
|
||
# Create the directory now if it doesn't exist
|
||
# Verify mysql user exists before attempting ownership change
|
||
if [[ ! -d /run/mysqld ]]; then
|
||
mkdir -p /run/mysqld
|
||
# Set permissions first (works regardless of user existence)
|
||
chmod 755 /run/mysqld
|
||
# Set ownership only if mysql user exists
|
||
if getent passwd mysql >/dev/null 2>&1; then
|
||
chown mysql:mysql /run/mysqld
|
||
else
|
||
msg_warn "mysql user not found - directory created with correct permissions but ownership not set"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Creates MariaDB database with user, charset and optional extra grants/modes
|
||
#
|
||
# Description:
|
||
# - Generates password if empty
|
||
# - Creates database with utf8mb4_unicode_ci
|
||
# - Creates local user with password
|
||
# - Grants full access to this DB
|
||
# - Optional: apply extra GRANT statements (comma-separated)
|
||
# - Optional: apply custom GLOBAL sql_mode
|
||
# - Saves credentials to file
|
||
# - Exports variables for use in calling script
|
||
#
|
||
# Usage:
|
||
# MARIADB_DB_NAME="myapp_db" MARIADB_DB_USER="myapp_user" setup_mariadb_db
|
||
# MARIADB_DB_NAME="domain_monitor" MARIADB_DB_USER="domainmonitor" setup_mariadb_db
|
||
# MARIADB_DB_NAME="myapp" MARIADB_DB_USER="myapp" MARIADB_DB_EXTRA_GRANTS="GRANT SELECT ON \`mysql\`.\`time_zone_name\`" setup_mariadb_db
|
||
# MARIADB_DB_NAME="ghostfolio" MARIADB_DB_USER="ghostfolio" MARIADB_DB_SQL_MODE="" setup_mariadb_db
|
||
#
|
||
# Variables:
|
||
# MARIADB_DB_NAME - Database name (required)
|
||
# MARIADB_DB_USER - Database user (required)
|
||
# MARIADB_DB_PASS - User password (optional, auto-generated if empty)
|
||
# MARIADB_DB_EXTRA_GRANTS - Comma-separated GRANT statements (optional)
|
||
# Example: "GRANT SELECT ON \`mysql\`.\`time_zone_name\`"
|
||
# MARIADB_DB_SQL_MODE - Optional global sql_mode override (e.g. "", "STRICT_TRANS_TABLES")
|
||
# MARIADB_DB_CREDS_FILE - Credentials file path (optional, default: ~/${APPLICATION}.creds)
|
||
#
|
||
# Exports:
|
||
# MARIADB_DB_NAME, MARIADB_DB_USER, MARIADB_DB_PASS
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_mariadb_db() {
|
||
if [[ -z "${MARIADB_DB_NAME:-}" || -z "${MARIADB_DB_USER:-}" ]]; then
|
||
msg_error "MARIADB_DB_NAME and MARIADB_DB_USER must be set before calling setup_mariadb_db"
|
||
return 65
|
||
fi
|
||
|
||
if [[ -z "${MARIADB_DB_PASS:-}" ]]; then
|
||
MARIADB_DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13)
|
||
fi
|
||
|
||
msg_info "Setting up MariaDB Database"
|
||
|
||
$STD mariadb -u root -e "CREATE DATABASE \`$MARIADB_DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||
$STD mariadb -u root -e "CREATE USER '$MARIADB_DB_USER'@'localhost' IDENTIFIED BY '$MARIADB_DB_PASS';"
|
||
$STD mariadb -u root -e "GRANT ALL ON \`$MARIADB_DB_NAME\`.* TO '$MARIADB_DB_USER'@'localhost';"
|
||
|
||
# Optional extra grants
|
||
if [[ -n "${MARIADB_DB_EXTRA_GRANTS:-}" ]]; then
|
||
IFS=',' read -ra G_LIST <<<"${MARIADB_DB_EXTRA_GRANTS:-}"
|
||
for g in "${G_LIST[@]}"; do
|
||
g=$(echo "$g" | xargs)
|
||
$STD mariadb -u root -e "$g TO '$MARIADB_DB_USER'@'localhost';"
|
||
done
|
||
fi
|
||
|
||
# Optional sql_mode override
|
||
if [[ -n "${MARIADB_DB_SQL_MODE:-}" ]]; then
|
||
$STD mariadb -u root -e "SET GLOBAL sql_mode='${MARIADB_DB_SQL_MODE:-}';"
|
||
fi
|
||
|
||
$STD mariadb -u root -e "FLUSH PRIVILEGES;"
|
||
|
||
local app_name="${APPLICATION,,}"
|
||
local CREDS_FILE="${MARIADB_DB_CREDS_FILE:-${HOME}/${app_name}.creds}"
|
||
{
|
||
echo "MariaDB Credentials"
|
||
echo "Database: $MARIADB_DB_NAME"
|
||
echo "User: $MARIADB_DB_USER"
|
||
echo "Password: $MARIADB_DB_PASS"
|
||
} >>"$CREDS_FILE"
|
||
|
||
msg_ok "Set up MariaDB Database"
|
||
|
||
export MARIADB_DB_NAME
|
||
export MARIADB_DB_USER
|
||
export MARIADB_DB_PASS
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or updates MeiliSearch search engine.
|
||
#
|
||
# Description:
|
||
# - Fresh install: Downloads binary, creates config/service, starts
|
||
# - Update: Checks for new release, updates binary if available
|
||
# - Waits for service to be ready before returning
|
||
# - Exports API keys for use by caller
|
||
#
|
||
# Variables:
|
||
# MEILISEARCH_BIND - Bind address (default: 127.0.0.1:7700)
|
||
# MEILISEARCH_ENV - Environment: production/development (default: production)
|
||
# MEILISEARCH_DB_PATH - Database path (default: /var/lib/meilisearch/data)
|
||
#
|
||
# Exports:
|
||
# MEILISEARCH_MASTER_KEY - The master key for admin access
|
||
# MEILISEARCH_API_KEY - The default search API key
|
||
# MEILISEARCH_API_KEY_UID - The UID of the default API key
|
||
#
|
||
# Example (install script):
|
||
# setup_meilisearch
|
||
#
|
||
# Example (CT update_script):
|
||
# setup_meilisearch
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_meilisearch() {
|
||
local MEILISEARCH_BIND="${MEILISEARCH_BIND:-127.0.0.1:7700}"
|
||
local MEILISEARCH_ENV="${MEILISEARCH_ENV:-production}"
|
||
local MEILISEARCH_DB_PATH="${MEILISEARCH_DB_PATH:-/var/lib/meilisearch/data}"
|
||
local MEILISEARCH_DUMP_DIR="${MEILISEARCH_DUMP_DIR:-/var/lib/meilisearch/dumps}"
|
||
local MEILISEARCH_SNAPSHOT_DIR="${MEILISEARCH_SNAPSHOT_DIR:-/var/lib/meilisearch/snapshots}"
|
||
|
||
# Get bind address for health checks
|
||
local MEILISEARCH_HOST="${MEILISEARCH_BIND%%:*}"
|
||
local MEILISEARCH_PORT="${MEILISEARCH_BIND##*:}"
|
||
[[ "$MEILISEARCH_HOST" == "0.0.0.0" ]] && MEILISEARCH_HOST="127.0.0.1"
|
||
|
||
# Update mode: MeiliSearch already installed
|
||
if [[ -f /usr/bin/meilisearch ]]; then
|
||
if check_for_gh_release "meilisearch" "meilisearch/meilisearch"; then
|
||
msg_info "Updating MeiliSearch"
|
||
|
||
# Get current and new version for compatibility check
|
||
local CURRENT_VERSION NEW_VERSION
|
||
CURRENT_VERSION=$(/usr/bin/meilisearch --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) || CURRENT_VERSION="0.0.0"
|
||
NEW_VERSION="${CHECK_UPDATE_RELEASE#v}"
|
||
|
||
# Extract major.minor for comparison (Meilisearch requires dump/restore between minor versions)
|
||
local CURRENT_MAJOR_MINOR NEW_MAJOR_MINOR
|
||
CURRENT_MAJOR_MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f1,2)
|
||
NEW_MAJOR_MINOR=$(echo "$NEW_VERSION" | cut -d. -f1,2)
|
||
|
||
# Determine if migration is needed (different major.minor = incompatible DB format)
|
||
local NEEDS_MIGRATION=false
|
||
if [[ "$CURRENT_MAJOR_MINOR" != "$NEW_MAJOR_MINOR" ]]; then
|
||
NEEDS_MIGRATION=true
|
||
msg_info "MeiliSearch version change detected (${CURRENT_VERSION} → ${NEW_VERSION}), preparing data migration"
|
||
fi
|
||
|
||
# Read config values for dump/restore
|
||
local MEILI_HOST MEILI_PORT MEILI_MASTER_KEY MEILI_DUMP_DIR
|
||
MEILI_HOST="${MEILISEARCH_HOST:-127.0.0.1}"
|
||
MEILI_PORT="${MEILISEARCH_PORT:-7700}"
|
||
MEILI_DUMP_DIR="${MEILISEARCH_DUMP_DIR:-/var/lib/meilisearch/dumps}"
|
||
MEILI_MASTER_KEY=$(grep -E "^master_key\s*=" /etc/meilisearch.toml 2>/dev/null | sed 's/.*=\s*"\(.*\)"/\1/' | tr -d ' ' || true)
|
||
|
||
# Create dump before update if migration is needed
|
||
local DUMP_UID=""
|
||
if [[ "$NEEDS_MIGRATION" == "true" ]] && [[ -n "$MEILI_MASTER_KEY" ]]; then
|
||
msg_info "Creating MeiliSearch data dump before upgrade"
|
||
|
||
# Trigger dump creation
|
||
local DUMP_RESPONSE
|
||
DUMP_RESPONSE=$(curl -s -X POST "http://${MEILI_HOST}:${MEILI_PORT}/dumps" \
|
||
-H "Authorization: Bearer ${MEILI_MASTER_KEY}" \
|
||
-H "Content-Type: application/json" 2>/dev/null) || true
|
||
|
||
# The initial response only contains taskUid, not dumpUid
|
||
# dumpUid is only available after the task completes
|
||
local TASK_UID
|
||
TASK_UID=$(echo "$DUMP_RESPONSE" | grep -oP '"taskUid":\s*\K[0-9]+' || true)
|
||
|
||
if [[ -n "$TASK_UID" ]]; then
|
||
msg_info "Waiting for dump task ${TASK_UID} to complete..."
|
||
local MAX_WAIT=120
|
||
local WAITED=0
|
||
local TASK_RESULT=""
|
||
|
||
while [[ $WAITED -lt $MAX_WAIT ]]; do
|
||
TASK_RESULT=$(curl -s "http://${MEILI_HOST}:${MEILI_PORT}/tasks/${TASK_UID}" \
|
||
-H "Authorization: Bearer ${MEILI_MASTER_KEY}" 2>/dev/null) || true
|
||
|
||
local TASK_STATUS
|
||
TASK_STATUS=$(echo "$TASK_RESULT" | grep -oP '"status":\s*"\K[^"]+' || true)
|
||
|
||
if [[ "$TASK_STATUS" == "succeeded" ]]; then
|
||
# Extract dumpUid from the completed task details
|
||
DUMP_UID=$(echo "$TASK_RESULT" | grep -oP '"dumpUid":\s*"\K[^"]+' || true)
|
||
if [[ -n "$DUMP_UID" ]]; then
|
||
msg_ok "MeiliSearch dump created successfully: ${DUMP_UID}"
|
||
else
|
||
msg_warn "Dump task succeeded but could not extract dumpUid"
|
||
fi
|
||
break
|
||
elif [[ "$TASK_STATUS" == "failed" ]]; then
|
||
local ERROR_MSG
|
||
ERROR_MSG=$(echo "$TASK_RESULT" | grep -oP '"message":\s*"\K[^"]+' || echo "Unknown error")
|
||
msg_warn "MeiliSearch dump failed: ${ERROR_MSG}"
|
||
break
|
||
fi
|
||
sleep 2
|
||
WAITED=$((WAITED + 2))
|
||
done
|
||
|
||
if [[ $WAITED -ge $MAX_WAIT ]]; then
|
||
msg_warn "MeiliSearch dump timed out after ${MAX_WAIT}s"
|
||
fi
|
||
else
|
||
msg_warn "Could not trigger MeiliSearch dump (no taskUid in response)"
|
||
msg_info "Response was: ${DUMP_RESPONSE:-empty}"
|
||
fi
|
||
fi
|
||
|
||
# If migration is needed but dump failed, we have options:
|
||
# 1. Abort the update (safest, but annoying)
|
||
# 2. Backup data directory and proceed (allows manual recovery)
|
||
# 3. Just proceed and hope for the best (dangerous)
|
||
# We choose option 2: backup and proceed with warning
|
||
if [[ "$NEEDS_MIGRATION" == "true" ]] && [[ -z "$DUMP_UID" ]]; then
|
||
local MEILI_DB_PATH
|
||
MEILI_DB_PATH=$(grep -E "^db_path\s*=" /etc/meilisearch.toml 2>/dev/null | sed 's/.*=\s*"\(.*\)"/\1/' | tr -d ' ' || true)
|
||
MEILI_DB_PATH="${MEILI_DB_PATH:-/var/lib/meilisearch/data}"
|
||
|
||
if [[ -d "$MEILI_DB_PATH" ]] && [[ -n "$(ls -A "$MEILI_DB_PATH" 2>/dev/null)" ]]; then
|
||
local BACKUP_PATH="${MEILI_DB_PATH}.backup.$(date +%Y%m%d%H%M%S)"
|
||
msg_warn "Backing up MeiliSearch data to ${BACKUP_PATH}"
|
||
mv "$MEILI_DB_PATH" "$BACKUP_PATH"
|
||
mkdir -p "$MEILI_DB_PATH"
|
||
msg_info "Data backed up. After update, you may need to reindex your data."
|
||
msg_info "Old data is preserved at: ${BACKUP_PATH}"
|
||
fi
|
||
fi
|
||
|
||
# Stop service and update binary
|
||
systemctl stop meilisearch
|
||
fetch_and_deploy_gh_release "meilisearch" "meilisearch/meilisearch" "binary"
|
||
|
||
# If migration needed and dump was created, remove old data and import dump
|
||
if [[ "$NEEDS_MIGRATION" == "true" ]] && [[ -n "$DUMP_UID" ]]; then
|
||
local MEILI_DB_PATH
|
||
MEILI_DB_PATH=$(grep -E "^db_path\s*=" /etc/meilisearch.toml 2>/dev/null | sed 's/.*=\s*"\(.*\)"/\1/' | tr -d ' ' || true)
|
||
MEILI_DB_PATH="${MEILI_DB_PATH:-/var/lib/meilisearch/data}"
|
||
|
||
msg_info "Removing old MeiliSearch database for migration"
|
||
rm -rf "${MEILI_DB_PATH:?}"/*
|
||
|
||
# Import dump using CLI flag (this is the supported method)
|
||
local DUMP_FILE="${MEILI_DUMP_DIR}/${DUMP_UID}.dump"
|
||
if [[ -f "$DUMP_FILE" ]]; then
|
||
msg_info "Importing dump: ${DUMP_FILE}"
|
||
|
||
# Start meilisearch with --import-dump flag
|
||
# This is a one-time import that happens during startup
|
||
/usr/bin/meilisearch --config-file-path /etc/meilisearch.toml --import-dump "$DUMP_FILE" >/dev/null 2>&1 &
|
||
local MEILI_PID=$!
|
||
|
||
# Wait for meilisearch to become healthy (import happens during startup)
|
||
msg_info "Waiting for MeiliSearch to import and start..."
|
||
local MAX_WAIT=300
|
||
local WAITED=0
|
||
while [[ $WAITED -lt $MAX_WAIT ]]; do
|
||
if curl -sf "http://${MEILI_HOST}:${MEILI_PORT}/health" &>/dev/null; then
|
||
msg_ok "MeiliSearch is healthy after import"
|
||
break
|
||
fi
|
||
# Check if process is still running
|
||
if ! kill -0 $MEILI_PID 2>/dev/null; then
|
||
msg_warn "MeiliSearch process exited during import"
|
||
break
|
||
fi
|
||
sleep 3
|
||
WAITED=$((WAITED + 3))
|
||
done
|
||
|
||
# Stop the manual process
|
||
kill $MEILI_PID 2>/dev/null || true
|
||
wait $MEILI_PID 2>/dev/null || true
|
||
sleep 2
|
||
|
||
# Start via systemd for proper management
|
||
systemctl start meilisearch
|
||
|
||
if systemctl is-active --quiet meilisearch; then
|
||
msg_ok "MeiliSearch migrated successfully"
|
||
else
|
||
msg_warn "MeiliSearch failed to start after migration - check logs with: journalctl -u meilisearch"
|
||
fi
|
||
else
|
||
msg_warn "Dump file not found: ${DUMP_FILE}"
|
||
systemctl start meilisearch
|
||
fi
|
||
else
|
||
systemctl start meilisearch
|
||
fi
|
||
|
||
msg_ok "Updated MeiliSearch"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# Fresh install
|
||
msg_info "Setup MeiliSearch"
|
||
|
||
# Install binary
|
||
fetch_and_deploy_gh_release "meilisearch" "meilisearch/meilisearch" "binary" || {
|
||
msg_error "Failed to install MeiliSearch binary"
|
||
return 250
|
||
}
|
||
|
||
# Download default config
|
||
curl -fsSL https://raw.githubusercontent.com/meilisearch/meilisearch/latest/config.toml -o /etc/meilisearch.toml || {
|
||
msg_error "Failed to download MeiliSearch config"
|
||
msg_error "Hint: Check connectivity to raw.githubusercontent.com/meilisearch/meilisearch"
|
||
return 7
|
||
}
|
||
|
||
# Generate master key
|
||
MEILISEARCH_MASTER_KEY=$(openssl rand -base64 12)
|
||
export MEILISEARCH_MASTER_KEY
|
||
|
||
# Configure
|
||
sed -i \
|
||
-e "s|^env =.*|env = \"${MEILISEARCH_ENV}\"|" \
|
||
-e "s|^# master_key =.*|master_key = \"${MEILISEARCH_MASTER_KEY}\"|" \
|
||
-e "s|^db_path =.*|db_path = \"${MEILISEARCH_DB_PATH}\"|" \
|
||
-e "s|^dump_dir =.*|dump_dir = \"${MEILISEARCH_DUMP_DIR}\"|" \
|
||
-e "s|^snapshot_dir =.*|snapshot_dir = \"${MEILISEARCH_SNAPSHOT_DIR}\"|" \
|
||
-e 's|^# no_analytics = true|no_analytics = true|' \
|
||
-e "s|^http_addr =.*|http_addr = \"${MEILISEARCH_BIND}\"|" \
|
||
/etc/meilisearch.toml
|
||
|
||
# Create data directories
|
||
mkdir -p "${MEILISEARCH_DB_PATH}" "${MEILISEARCH_DUMP_DIR}" "${MEILISEARCH_SNAPSHOT_DIR}"
|
||
|
||
# Create systemd service
|
||
cat <<EOF >/etc/systemd/system/meilisearch.service
|
||
[Unit]
|
||
Description=Meilisearch
|
||
After=network.target
|
||
|
||
[Service]
|
||
ExecStart=/usr/bin/meilisearch --config-file-path /etc/meilisearch.toml
|
||
Restart=always
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
|
||
# Enable and start service
|
||
systemctl daemon-reload
|
||
systemctl enable -q --now meilisearch
|
||
|
||
# Wait for MeiliSearch to be ready (up to 30 seconds)
|
||
for i in {1..30}; do
|
||
if curl -s -o /dev/null -w "%{http_code}" "http://${MEILISEARCH_HOST}:${MEILISEARCH_PORT}/health" 2>/dev/null | grep -q "200"; then
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
# Verify service is running
|
||
if ! systemctl is-active --quiet meilisearch; then
|
||
msg_error "MeiliSearch service failed to start"
|
||
return 150
|
||
fi
|
||
|
||
# Get API keys with retry logic
|
||
MEILISEARCH_API_KEY=""
|
||
for i in {1..10}; do
|
||
MEILISEARCH_API_KEY=$(curl -s -X GET "http://${MEILISEARCH_HOST}:${MEILISEARCH_PORT}/keys" \
|
||
-H "Authorization: Bearer ${MEILISEARCH_MASTER_KEY}" 2>/dev/null |
|
||
grep -o '"key":"[^"]*"' | head -n 1 | sed 's/"key":"//;s/"//') || true
|
||
[[ -n "$MEILISEARCH_API_KEY" ]] && break
|
||
sleep 2
|
||
done
|
||
|
||
MEILISEARCH_API_KEY_UID=$(curl -s -X GET "http://${MEILISEARCH_HOST}:${MEILISEARCH_PORT}/keys" \
|
||
-H "Authorization: Bearer ${MEILISEARCH_MASTER_KEY}" 2>/dev/null |
|
||
grep -o '"uid":"[^"]*"' | head -n 1 | sed 's/"uid":"//;s/"//') || true
|
||
|
||
export MEILISEARCH_API_KEY
|
||
export MEILISEARCH_API_KEY_UID
|
||
|
||
# Cache version
|
||
local MEILISEARCH_VERSION
|
||
MEILISEARCH_VERSION=$(/usr/bin/meilisearch --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) || true
|
||
cache_installed_version "meilisearch" "${MEILISEARCH_VERSION:-unknown}"
|
||
|
||
msg_ok "Setup MeiliSearch ${MEILISEARCH_VERSION:-}"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or updates MongoDB to specified version.
|
||
#
|
||
# Description:
|
||
# - Preserves data across installations
|
||
# - Adds official MongoDB repo
|
||
#
|
||
# Variables:
|
||
# MONGO_VERSION - MongoDB version to install (e.g. 7.0, 8.2)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_mongodb() {
|
||
local MONGO_VERSION="${MONGO_VERSION:-8.0}"
|
||
local DISTRO_ID DISTRO_CODENAME
|
||
DISTRO_ID=$(get_os_info id)
|
||
DISTRO_CODENAME=$(get_os_info codename)
|
||
|
||
# Ensure non-interactive mode for all apt operations
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export NEEDRESTART_MODE=a
|
||
export NEEDRESTART_SUSPEND=1
|
||
|
||
# Check AVX support
|
||
if ! grep -qm1 'avx[^ ]*' /proc/cpuinfo; then
|
||
local major="${MONGO_VERSION%%.*}"
|
||
if ((major > 5)); then
|
||
msg_error "MongoDB ${MONGO_VERSION} requires AVX support, which is not available on this system."
|
||
return 236
|
||
fi
|
||
fi
|
||
|
||
case "$DISTRO_ID" in
|
||
ubuntu)
|
||
MONGO_BASE_URL="https://repo.mongodb.org/apt/ubuntu"
|
||
;;
|
||
debian)
|
||
MONGO_BASE_URL="https://repo.mongodb.org/apt/debian"
|
||
;;
|
||
*)
|
||
msg_error "Unsupported distribution: $DISTRO_ID"
|
||
return 238
|
||
;;
|
||
esac
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
INSTALLED_VERSION=$(is_tool_installed "mongodb" 2>/dev/null) || true
|
||
|
||
# Scenario 1: Already at target version - just update packages
|
||
if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" == "$MONGO_VERSION" ]]; then
|
||
msg_info "Update MongoDB $MONGO_VERSION"
|
||
|
||
ensure_apt_working || return 100
|
||
|
||
# Perform upgrade with retry logic
|
||
upgrade_packages_with_retry "mongodb-org" || {
|
||
msg_error "Failed to upgrade MongoDB"
|
||
return 100
|
||
}
|
||
cache_installed_version "mongodb" "$MONGO_VERSION"
|
||
msg_ok "Update MongoDB $MONGO_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version installed - clean upgrade
|
||
if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" != "$MONGO_VERSION" ]]; then
|
||
msg_info "Upgrade MongoDB from $INSTALLED_VERSION to $MONGO_VERSION"
|
||
remove_old_tool_version "mongodb"
|
||
else
|
||
msg_info "Setup MongoDB $MONGO_VERSION"
|
||
fi
|
||
|
||
cleanup_orphaned_sources
|
||
|
||
# Prepare repository (cleanup + validation)
|
||
prepare_repository_setup "mongodb" || {
|
||
msg_error "Failed to prepare MongoDB repository"
|
||
return 100
|
||
}
|
||
|
||
# Setup repository
|
||
# MongoDB 8.x versions beyond 8.0 reuse the server-8.0.asc PGP key
|
||
local MONGO_KEY_VERSION="${MONGO_VERSION}"
|
||
[[ "${MONGO_VERSION}" == 8.[1-9]* ]] && MONGO_KEY_VERSION="8.0"
|
||
manage_tool_repository "mongodb" "$MONGO_VERSION" "$MONGO_BASE_URL" \
|
||
"https://www.mongodb.org/static/pgp/server-${MONGO_KEY_VERSION}.asc" || {
|
||
msg_error "Failed to setup MongoDB repository"
|
||
return 100
|
||
}
|
||
|
||
# Wait for repo to settle
|
||
$STD apt update || {
|
||
msg_error "APT update failed — invalid MongoDB repo for ${DISTRO_ID}-${DISTRO_CODENAME}?"
|
||
return 100
|
||
}
|
||
|
||
# Install MongoDB with retry logic
|
||
install_packages_with_retry "mongodb-org" || {
|
||
msg_error "Failed to install MongoDB packages"
|
||
return 100
|
||
}
|
||
|
||
if ! command -v mongod >/dev/null 2>&1; then
|
||
msg_error "MongoDB binary not found after installation"
|
||
return 127
|
||
fi
|
||
|
||
mkdir -p /var/lib/mongodb
|
||
chown -R mongodb:mongodb /var/lib/mongodb
|
||
|
||
$STD systemctl enable mongod || {
|
||
msg_warn "Failed to enable mongod service"
|
||
}
|
||
safe_service_restart mongod
|
||
|
||
# Verify MongoDB version
|
||
local INSTALLED_VERSION
|
||
INSTALLED_VERSION=$(mongod --version 2>/dev/null | grep -oP 'db version v\K[0-9]+\.[0-9]+' | head -n1 || echo "0.0")
|
||
verify_tool_version "MongoDB" "$MONGO_VERSION" "$INSTALLED_VERSION" || true
|
||
|
||
cache_installed_version "mongodb" "$MONGO_VERSION"
|
||
msg_ok "Setup MongoDB $MONGO_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or upgrades MySQL.
|
||
#
|
||
# Description:
|
||
# - By default uses distro repository (Debian/Ubuntu apt) for stability
|
||
# - Optionally uses official MySQL repository for specific versions
|
||
# - Detects existing MySQL installation
|
||
# - Purges conflicting packages before installation
|
||
# - Supports clean upgrade
|
||
# - Handles Debian Trixie libaio1t64 transition
|
||
#
|
||
# Variables:
|
||
# USE_MYSQL_REPO - Use official MySQL repository (default: true)
|
||
# Set to "false" to use distro packages instead
|
||
# MYSQL_VERSION - MySQL version to install when using official repo
|
||
# (e.g. 8.0, 8.4) (default: 8.0)
|
||
#
|
||
# Examples:
|
||
# setup_mysql # Uses official MySQL repo, 8.0
|
||
# MYSQL_VERSION="8.4" setup_mysql # Specific version from MySQL repo
|
||
# USE_MYSQL_REPO=false setup_mysql # Uses distro package instead
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_mysql() {
|
||
local MYSQL_VERSION="${MYSQL_VERSION:-8.0}"
|
||
local USE_MYSQL_REPO="${USE_MYSQL_REPO:-true}"
|
||
local DISTRO_ID DISTRO_CODENAME
|
||
DISTRO_ID=$(get_os_info id)
|
||
DISTRO_CODENAME=$(get_os_info codename)
|
||
|
||
# Ensure non-interactive mode for all apt operations
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export NEEDRESTART_MODE=a
|
||
export NEEDRESTART_SUSPEND=1
|
||
|
||
# Get currently installed version
|
||
local CURRENT_VERSION=""
|
||
CURRENT_VERSION=$(is_tool_installed "mysql" 2>/dev/null) || true
|
||
|
||
# Scenario 1: Use distro repository (default, most stable)
|
||
if [[ "$USE_MYSQL_REPO" != "true" && "$USE_MYSQL_REPO" != "TRUE" && "$USE_MYSQL_REPO" != "1" ]]; then
|
||
msg_info "Setup MySQL (distro package)"
|
||
|
||
# If already installed, just update
|
||
if [[ -n "$CURRENT_VERSION" ]]; then
|
||
msg_info "Update MySQL $CURRENT_VERSION"
|
||
ensure_apt_working || return 100
|
||
upgrade_packages_with_retry "default-mysql-server" "default-mysql-client" ||
|
||
upgrade_packages_with_retry "mysql-server" "mysql-client" ||
|
||
upgrade_packages_with_retry "mariadb-server" "mariadb-client" || {
|
||
msg_error "Failed to upgrade MySQL/MariaDB packages"
|
||
return 100
|
||
}
|
||
cache_installed_version "mysql" "$CURRENT_VERSION"
|
||
msg_ok "Update MySQL $CURRENT_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Fresh install from distro repo
|
||
ensure_apt_working || return 100
|
||
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
# Try default-mysql-server first, fallback to mysql-server, then mariadb
|
||
if apt-cache search "^default-mysql-server$" 2>/dev/null | grep -q .; then
|
||
install_packages_with_retry "default-mysql-server" "default-mysql-client" || {
|
||
msg_warn "default-mysql-server failed, trying mysql-server"
|
||
install_packages_with_retry "mysql-server" "mysql-client" || {
|
||
msg_warn "mysql-server failed, trying mariadb as fallback"
|
||
install_packages_with_retry "mariadb-server" "mariadb-client" || {
|
||
msg_error "Failed to install any MySQL/MariaDB from distro repository"
|
||
return 100
|
||
}
|
||
}
|
||
}
|
||
elif apt-cache search "^mysql-server$" 2>/dev/null | grep -q .; then
|
||
install_packages_with_retry "mysql-server" "mysql-client" || {
|
||
msg_warn "mysql-server failed, trying mariadb as fallback"
|
||
install_packages_with_retry "mariadb-server" "mariadb-client" || {
|
||
msg_error "Failed to install any MySQL/MariaDB from distro repository"
|
||
return 100
|
||
}
|
||
}
|
||
else
|
||
# Distro doesn't have MySQL, use MariaDB
|
||
install_packages_with_retry "mariadb-server" "mariadb-client" || {
|
||
msg_error "Failed to install MariaDB from distro repository"
|
||
return 100
|
||
}
|
||
fi
|
||
|
||
# Get installed version
|
||
local INSTALLED_VERSION=""
|
||
INSTALLED_VERSION=$(is_tool_installed "mysql" 2>/dev/null) || true
|
||
if [[ -z "$INSTALLED_VERSION" ]]; then
|
||
INSTALLED_VERSION=$(is_tool_installed "mariadb" 2>/dev/null) || true
|
||
fi
|
||
cache_installed_version "mysql" "${INSTALLED_VERSION:-distro}"
|
||
msg_ok "Setup MySQL/MariaDB ${INSTALLED_VERSION:-from distro}"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Use official MySQL repository (USE_MYSQL_REPO=true)
|
||
# Scenario 2a: Already at target version - just update packages
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" == "$MYSQL_VERSION" ]]; then
|
||
msg_info "Update MySQL $MYSQL_VERSION"
|
||
|
||
ensure_apt_working || return 100
|
||
|
||
# Perform upgrade with retry logic (non-fatal if fails)
|
||
upgrade_packages_with_retry "mysql-server" "mysql-client" || {
|
||
msg_warn "MySQL package upgrade had issues, continuing with current version"
|
||
}
|
||
|
||
cache_installed_version "mysql" "$MYSQL_VERSION"
|
||
msg_ok "Update MySQL $MYSQL_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version installed - clean upgrade
|
||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "$MYSQL_VERSION" ]]; then
|
||
msg_info "Upgrade MySQL from $CURRENT_VERSION to $MYSQL_VERSION"
|
||
remove_old_tool_version "mysql"
|
||
else
|
||
msg_info "Setup MySQL $MYSQL_VERSION"
|
||
fi
|
||
|
||
# Prepare repository (cleanup + validation)
|
||
prepare_repository_setup "mysql" || {
|
||
msg_error "Failed to prepare MySQL repository"
|
||
return 100
|
||
}
|
||
|
||
# Debian 13+ Fix: MySQL 8.0 incompatible with libaio1t64, use 8.4 LTS
|
||
if [[ "$DISTRO_ID" == "debian" && "$DISTRO_CODENAME" =~ ^(trixie|forky|sid)$ ]]; then
|
||
msg_info "Debian ${DISTRO_CODENAME} detected → using MySQL 8.4 LTS (libaio1t64 compatible)"
|
||
|
||
if ! download_gpg_key "https://repo.mysql.com/RPM-GPG-KEY-mysql-2023" "/etc/apt/keyrings/mysql.gpg" "dearmor"; then
|
||
msg_error "Failed to import MySQL GPG key"
|
||
return 7
|
||
fi
|
||
|
||
cat >/etc/apt/sources.list.d/mysql.sources <<EOF
|
||
Types: deb
|
||
URIs: https://repo.mysql.com/apt/debian/
|
||
Suites: bookworm
|
||
Components: mysql-8.4-lts
|
||
Architectures: $(dpkg --print-architecture)
|
||
Signed-By: /etc/apt/keyrings/mysql.gpg
|
||
EOF
|
||
|
||
$STD apt update || {
|
||
msg_error "Failed to update APT for MySQL 8.4 LTS"
|
||
return 100
|
||
}
|
||
|
||
# Install with retry logic
|
||
if ! install_packages_with_retry "mysql-community-server" "mysql-community-client"; then
|
||
msg_warn "MySQL 8.4 LTS installation failed – falling back to MariaDB"
|
||
cleanup_old_repo_files "mysql"
|
||
$STD apt update
|
||
install_packages_with_retry "mariadb-server" "mariadb-client" || {
|
||
msg_error "Failed to install database engine (MySQL/MariaDB fallback)"
|
||
return 100
|
||
}
|
||
msg_ok "Setup Database Engine (MariaDB fallback on Debian ${DISTRO_CODENAME})"
|
||
return 0
|
||
fi
|
||
|
||
cache_installed_version "mysql" "8.4"
|
||
msg_ok "Setup MySQL 8.4 LTS (Debian ${DISTRO_CODENAME})"
|
||
return 0
|
||
fi
|
||
|
||
# Standard setup for other distributions
|
||
local SUITE
|
||
if [[ "$DISTRO_ID" == "debian" ]]; then
|
||
case "$DISTRO_CODENAME" in
|
||
bookworm | bullseye) SUITE="$DISTRO_CODENAME" ;;
|
||
*) SUITE="bookworm" ;;
|
||
esac
|
||
else
|
||
SUITE=$(get_fallback_suite "$DISTRO_ID" "$DISTRO_CODENAME" "https://repo.mysql.com/apt/${DISTRO_ID}")
|
||
fi
|
||
|
||
# Setup repository
|
||
manage_tool_repository "mysql" "$MYSQL_VERSION" "https://repo.mysql.com/apt/${DISTRO_ID}" \
|
||
"https://repo.mysql.com/RPM-GPG-KEY-mysql-2023" || {
|
||
msg_error "Failed to setup MySQL repository"
|
||
return 100
|
||
}
|
||
|
||
ensure_apt_working || return 100
|
||
|
||
# Try multiple package names with retry logic
|
||
local mysql_install_success=false
|
||
|
||
if apt-cache search "^mysql-server$" 2>/dev/null | grep -q . &&
|
||
install_packages_with_retry "mysql-server" "mysql-client"; then
|
||
mysql_install_success=true
|
||
elif apt-cache search "^mysql-community-server$" 2>/dev/null | grep -q . &&
|
||
install_packages_with_retry "mysql-community-server" "mysql-community-client"; then
|
||
mysql_install_success=true
|
||
elif apt-cache search "^mysql$" 2>/dev/null | grep -q . &&
|
||
install_packages_with_retry "mysql"; then
|
||
mysql_install_success=true
|
||
fi
|
||
|
||
if [[ "$mysql_install_success" == false ]]; then
|
||
msg_error "MySQL ${MYSQL_VERSION} package not available for suite ${SUITE}"
|
||
return 100
|
||
fi
|
||
|
||
# Verify mysql command is accessible
|
||
if ! command -v mysql >/dev/null 2>&1; then
|
||
hash -r
|
||
if ! command -v mysql >/dev/null 2>&1; then
|
||
msg_error "MySQL installed but mysql command still not found"
|
||
return 127
|
||
fi
|
||
fi
|
||
|
||
cache_installed_version "mysql" "$MYSQL_VERSION"
|
||
msg_ok "Setup MySQL $MYSQL_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs Node.js and optional global modules.
|
||
#
|
||
# Description:
|
||
# - Installs specified Node.js version using NodeSource APT repo
|
||
# - Optionally installs or updates global npm modules
|
||
#
|
||
# Variables:
|
||
# NODE_VERSION - Node.js version to install (default: 24 LTS)
|
||
# NODE_MODULE - Comma-separated list of global modules (e.g. "yarn,@vue/cli@5.0.0")
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_nodejs() {
|
||
local NODE_VERSION="${NODE_VERSION:-24}"
|
||
local NODE_MODULE="${NODE_MODULE:-}"
|
||
|
||
# ALWAYS clean up legacy installations first (nvm, etc.) to prevent conflicts
|
||
cleanup_legacy_install "nodejs"
|
||
|
||
# Get currently installed version
|
||
local CURRENT_NODE_VERSION=""
|
||
CURRENT_NODE_VERSION=$(is_tool_installed "nodejs" 2>/dev/null) || true
|
||
|
||
# Ensure jq is available for JSON parsing
|
||
if ! command -v jq &>/dev/null; then
|
||
$STD apt update
|
||
$STD apt install -y jq || {
|
||
msg_error "Failed to install jq"
|
||
return 100
|
||
}
|
||
fi
|
||
|
||
# Scenario 1: Already installed at target version - upgrade to latest minor/patch + update packages/modules
|
||
if [[ -n "$CURRENT_NODE_VERSION" && "$CURRENT_NODE_VERSION" == "$NODE_VERSION" ]]; then
|
||
msg_info "Update Node.js $NODE_VERSION"
|
||
|
||
ensure_apt_working || return 100
|
||
|
||
# Upgrade to the latest minor/patch release from NodeSource
|
||
$STD apt-get install -y --only-upgrade nodejs 2>/dev/null || true
|
||
|
||
# Pin npm to 11.11.0 to work around Node.js 22.22.2 regression (nodejs/node#62425)
|
||
$STD npm install -g npm@11.11.0 2>/dev/null || true
|
||
|
||
cache_installed_version "nodejs" "$NODE_VERSION"
|
||
msg_ok "Update Node.js $NODE_VERSION"
|
||
else
|
||
# Scenario 2: Different version installed - clean upgrade
|
||
if [[ -n "$CURRENT_NODE_VERSION" && "$CURRENT_NODE_VERSION" != "$NODE_VERSION" ]]; then
|
||
msg_info "Upgrade Node.js from $CURRENT_NODE_VERSION to $NODE_VERSION"
|
||
remove_old_tool_version "nodejs"
|
||
else
|
||
msg_info "Setup Node.js $NODE_VERSION"
|
||
fi
|
||
|
||
# Remove ALL Debian nodejs packages BEFORE adding NodeSource repo
|
||
if dpkg -l 2>/dev/null | grep -qE "^ii.*(nodejs|libnode|node-cjs|node-acorn|node-balanced|node-brace|node-minimatch|node-undici|node-xtend|node-corepack)"; then
|
||
msg_info "Removing Debian-packaged Node.js and dependencies"
|
||
$STD apt purge -y nodejs nodejs-doc libnode* node-* 2>/dev/null || true
|
||
$STD apt autoremove -y 2>/dev/null || true
|
||
$STD apt clean 2>/dev/null || true
|
||
fi
|
||
|
||
# Remove any APT pinning (not needed)
|
||
rm -f /etc/apt/preferences.d/nodesource 2>/dev/null || true
|
||
|
||
# Prepare repository (cleanup + validation)
|
||
prepare_repository_setup "nodesource" || {
|
||
msg_error "Failed to prepare Node.js repository"
|
||
return 250
|
||
}
|
||
|
||
# Setup NodeSource repository
|
||
manage_tool_repository "nodejs" "$NODE_VERSION" "https://deb.nodesource.com/node_${NODE_VERSION}.x" "https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key" || {
|
||
msg_error "Failed to setup Node.js repository"
|
||
return 250
|
||
}
|
||
|
||
# Force APT cache refresh after repository setup
|
||
$STD apt update || {
|
||
msg_warn "apt update failed after Node.js repository setup"
|
||
}
|
||
|
||
ensure_dependencies curl ca-certificates gnupg
|
||
|
||
install_packages_with_retry "nodejs" || {
|
||
msg_error "Failed to install Node.js ${NODE_VERSION} from NodeSource"
|
||
return 100
|
||
}
|
||
|
||
# Verify Node.js was installed correctly
|
||
if ! command -v node >/dev/null 2>&1; then
|
||
msg_error "Node.js binary not found after installation"
|
||
return 127
|
||
fi
|
||
|
||
local INSTALLED_NODE_VERSION
|
||
INSTALLED_NODE_VERSION=$(node -v 2>/dev/null | grep -oP '^v\K[0-9]+' || echo "0")
|
||
verify_tool_version "Node.js" "$NODE_VERSION" "$INSTALLED_NODE_VERSION" || true
|
||
|
||
# Verify npm is available (should come with NodeSource nodejs)
|
||
if ! command -v npm >/dev/null 2>&1; then
|
||
msg_error "npm not found after Node.js installation - repository issue?"
|
||
return 127
|
||
fi
|
||
|
||
# Pin npm to 11.11.0 to work around Node.js 22.22.2 regression (nodejs/node#62425)
|
||
local NPM_VERSION
|
||
NPM_VERSION=$(npm -v 2>/dev/null || echo "0")
|
||
if [[ "$NPM_VERSION" != "0" ]]; then
|
||
$STD npm install -g npm@11.11.0 2>/dev/null || {
|
||
msg_warn "Failed to update npm to 11.11.0 (continuing with bundled npm $NPM_VERSION)"
|
||
}
|
||
fi
|
||
|
||
cache_installed_version "nodejs" "$NODE_VERSION"
|
||
msg_ok "Setup Node.js $NODE_VERSION"
|
||
fi
|
||
|
||
# Set a safe default heap limit for Node.js builds if not explicitly provided.
|
||
# Priority:
|
||
# 1) NODE_OPTIONS (caller/user override)
|
||
# 2) NODE_MAX_OLD_SPACE_SIZE (explicit MB override)
|
||
# 3) var_ram (LXC memory setting, MB)
|
||
# 4) /proc/meminfo (runtime memory detection)
|
||
# Auto value is clamped to 1024..12288 MB.
|
||
if [[ -z "${NODE_OPTIONS:-}" ]]; then
|
||
local node_heap_mb=""
|
||
|
||
if [[ -n "${NODE_MAX_OLD_SPACE_SIZE:-}" ]] && [[ "${NODE_MAX_OLD_SPACE_SIZE}" =~ ^[0-9]+$ ]]; then
|
||
node_heap_mb="${NODE_MAX_OLD_SPACE_SIZE}"
|
||
elif [[ -n "${var_ram:-}" ]] && [[ "${var_ram}" =~ ^[0-9]+$ ]]; then
|
||
node_heap_mb=$((var_ram * 75 / 100))
|
||
else
|
||
local total_mem_kb=""
|
||
total_mem_kb=$(awk '/^MemTotal:/ {print $2; exit}' /proc/meminfo 2>/dev/null || echo "")
|
||
if [[ "$total_mem_kb" =~ ^[0-9]+$ ]]; then
|
||
local total_mem_mb=$((total_mem_kb / 1024))
|
||
node_heap_mb=$((total_mem_mb * 75 / 100))
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$node_heap_mb" ]] || ((node_heap_mb < 1024)); then
|
||
node_heap_mb=1024
|
||
elif ((node_heap_mb > 12288)); then
|
||
node_heap_mb=12288
|
||
fi
|
||
|
||
export NODE_OPTIONS="--max-old-space-size=${node_heap_mb}"
|
||
fi
|
||
|
||
# Ensure valid working directory for npm (avoids uv_cwd error)
|
||
if [[ ! -d /opt ]]; then
|
||
mkdir -p /opt
|
||
fi
|
||
cd /opt || {
|
||
msg_error "Failed to set safe working directory before npm install"
|
||
return 127
|
||
}
|
||
|
||
# Install global Node modules
|
||
if [[ -n "$NODE_MODULE" ]]; then
|
||
IFS=',' read -ra MODULES <<<"$NODE_MODULE"
|
||
|
||
# Pin pnpm to v10 to avoid breaking changes from newer major versions
|
||
for i in "${!MODULES[@]}"; do
|
||
if [[ "${MODULES[$i]}" =~ ^pnpm(@.*)?$ ]]; then
|
||
MODULES[$i]="pnpm@^10"
|
||
fi
|
||
done
|
||
|
||
local failed_modules=0
|
||
for mod in "${MODULES[@]}"; do
|
||
local MODULE_NAME MODULE_REQ_VERSION MODULE_INSTALLED_VERSION
|
||
if [[ "$mod" == @*/*@* ]]; then
|
||
# Scoped package with version, e.g. @vue/cli-service@latest
|
||
MODULE_NAME="${mod%@*}"
|
||
MODULE_REQ_VERSION="${mod##*@}"
|
||
elif [[ "$mod" == *"@"* ]]; then
|
||
# Unscoped package with version, e.g. yarn@latest
|
||
MODULE_NAME="${mod%@*}"
|
||
MODULE_REQ_VERSION="${mod##*@}"
|
||
else
|
||
# No version specified
|
||
MODULE_NAME="$mod"
|
||
MODULE_REQ_VERSION="latest"
|
||
fi
|
||
|
||
# Check if the module is already installed
|
||
if $STD npm list -g --depth=0 "$MODULE_NAME" 2>&1 | grep -q "$MODULE_NAME@"; then
|
||
MODULE_INSTALLED_VERSION="$(npm list -g --depth=0 "$MODULE_NAME" 2>&1 | grep "$MODULE_NAME@" | awk -F@ '{print $2}' 2>/dev/null | tr -d '[:space:]' || echo '')"
|
||
if [[ "$MODULE_REQ_VERSION" != "latest" && "$MODULE_REQ_VERSION" != "$MODULE_INSTALLED_VERSION" ]]; then
|
||
msg_info "Updating $MODULE_NAME from v$MODULE_INSTALLED_VERSION to v$MODULE_REQ_VERSION"
|
||
if ! $STD npm install -g "${MODULE_NAME}@${MODULE_REQ_VERSION}" 2>/dev/null; then
|
||
msg_warn "Failed to update $MODULE_NAME to version $MODULE_REQ_VERSION"
|
||
((failed_modules++)) || true
|
||
continue
|
||
fi
|
||
elif [[ "$MODULE_REQ_VERSION" == "latest" ]]; then
|
||
msg_info "Updating $MODULE_NAME to latest version"
|
||
if ! $STD npm install -g "${MODULE_NAME}@latest" 2>/dev/null; then
|
||
msg_warn "Failed to update $MODULE_NAME to latest version"
|
||
((failed_modules++)) || true
|
||
continue
|
||
fi
|
||
fi
|
||
else
|
||
msg_info "Installing $MODULE_NAME@$MODULE_REQ_VERSION"
|
||
if ! $STD npm install -g "${MODULE_NAME}@${MODULE_REQ_VERSION}" 2>/dev/null; then
|
||
msg_warn "Failed to install $MODULE_NAME@$MODULE_REQ_VERSION"
|
||
((failed_modules++)) || true
|
||
continue
|
||
fi
|
||
fi
|
||
done
|
||
if [[ $failed_modules -eq 0 ]]; then
|
||
msg_ok "Installed Node.js modules: $NODE_MODULE"
|
||
else
|
||
msg_warn "Installed Node.js modules with $failed_modules failure(s): $NODE_MODULE"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs PHP with selected modules and configures Apache/FPM support.
|
||
#
|
||
# Description:
|
||
# - Adds Sury PHP repo if needed
|
||
# - Installs default and user-defined modules
|
||
# - Patches php.ini for CLI, Apache, and FPM as needed
|
||
# - Handles built-in modules gracefully (e.g., opcache in PHP 8.5+)
|
||
# - Skips unavailable packages without failing
|
||
#
|
||
# Variables:
|
||
# PHP_VERSION - PHP version to install (default: 8.4)
|
||
# PHP_MODULE - Additional comma-separated modules
|
||
# PHP_APACHE - Set YES to enable PHP with Apache
|
||
# PHP_FPM - Set YES to enable PHP-FPM
|
||
# PHP_MEMORY_LIMIT - (default: 512M)
|
||
# PHP_UPLOAD_MAX_FILESIZE - (default: 128M)
|
||
# PHP_POST_MAX_SIZE - (default: 128M)
|
||
# PHP_MAX_EXECUTION_TIME - (default: 300)
|
||
#
|
||
# Notes on modules:
|
||
# - Base modules (always installed): bcmath, cli, curl, gd, intl, mbstring,
|
||
# readline, xml, zip, common
|
||
# - Extended modules (commonly needed): mysql, sqlite3, pgsql, redis,
|
||
# imagick, bz2, ldap, soap, imap, gmp, apcu
|
||
# - Some modules are built-in depending on PHP version:
|
||
# * PHP 8.5+: opcache is built-in (no separate package)
|
||
# * All versions: ctype, fileinfo, iconv, tokenizer, phar, posix, etc.
|
||
# are part of php-common
|
||
# - Unavailable modules are skipped with a warning, not an error
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_php() {
|
||
local PHP_VERSION="${PHP_VERSION:-8.4}"
|
||
local PHP_MODULE="${PHP_MODULE:-}"
|
||
local PHP_APACHE="${PHP_APACHE:-NO}"
|
||
local PHP_FPM="${PHP_FPM:-NO}"
|
||
local DISTRO_ID DISTRO_CODENAME
|
||
DISTRO_ID=$(get_os_info id)
|
||
DISTRO_CODENAME=$(get_os_info codename)
|
||
|
||
# Parse version for compatibility checks
|
||
local PHP_MAJOR="${PHP_VERSION%%.*}"
|
||
local PHP_MINOR="${PHP_VERSION#*.}"
|
||
PHP_MINOR="${PHP_MINOR%%.*}"
|
||
|
||
# Modules that are ALWAYS part of php-common (no separate package needed)
|
||
# These are either built-in or virtual packages provided by php-common
|
||
local BUILTIN_MODULES="calendar,ctype,exif,ffi,fileinfo,ftp,gettext,iconv,pdo,phar,posix,shmop,sockets,sysvmsg,sysvsem,sysvshm,tokenizer"
|
||
|
||
# Modules that became built-in in specific PHP versions
|
||
# PHP 8.5+: opcache is now part of the core
|
||
local BUILTIN_85=""
|
||
if [[ "$PHP_MAJOR" -gt 8 ]] || [[ "$PHP_MAJOR" -eq 8 && "$PHP_MINOR" -ge 5 ]]; then
|
||
BUILTIN_85="opcache"
|
||
fi
|
||
|
||
# Base modules - essential for most PHP applications
|
||
# Note: 'common' provides many built-in extensions
|
||
local BASE_MODULES="cli,common,bcmath,curl,dom,gd,gmp,intl,mbstring,readline,xml,zip"
|
||
|
||
# Add opcache only for PHP < 8.5 (it's built-in starting from 8.5)
|
||
if [[ "$PHP_MAJOR" -lt 8 ]] || [[ "$PHP_MAJOR" -eq 8 && "$PHP_MINOR" -lt 5 ]]; then
|
||
BASE_MODULES="${BASE_MODULES},opcache"
|
||
fi
|
||
|
||
# Extended default modules - commonly needed by web applications
|
||
# These cover ~90% of typical use cases without bloat
|
||
local EXTENDED_MODULES="mysql,sqlite3,pgsql,redis,imagick,bz2,apcu"
|
||
|
||
local COMBINED_MODULES="${BASE_MODULES},${EXTENDED_MODULES}"
|
||
|
||
local PHP_MEMORY_LIMIT="${PHP_MEMORY_LIMIT:-512M}"
|
||
local PHP_UPLOAD_MAX_FILESIZE="${PHP_UPLOAD_MAX_FILESIZE:-128M}"
|
||
local PHP_POST_MAX_SIZE="${PHP_POST_MAX_SIZE:-128M}"
|
||
local PHP_MAX_EXECUTION_TIME="${PHP_MAX_EXECUTION_TIME:-300}"
|
||
|
||
# Merge with user-defined modules
|
||
if [[ -n "$PHP_MODULE" ]]; then
|
||
COMBINED_MODULES="${COMBINED_MODULES},${PHP_MODULE}"
|
||
fi
|
||
|
||
# Filter out built-in modules (they don't have separate packages)
|
||
local FILTERED_MODULES=""
|
||
IFS=',' read -ra ALL_MODULES <<<"$COMBINED_MODULES"
|
||
for mod in "${ALL_MODULES[@]}"; do
|
||
mod=$(echo "$mod" | tr -d '[:space:]')
|
||
[[ -z "$mod" ]] && continue
|
||
|
||
# Skip if it's a known built-in module
|
||
if echo ",$BUILTIN_MODULES,$BUILTIN_85," | grep -qi ",$mod,"; then
|
||
continue
|
||
fi
|
||
|
||
# Add to filtered list
|
||
if [[ -z "$FILTERED_MODULES" ]]; then
|
||
FILTERED_MODULES="$mod"
|
||
else
|
||
FILTERED_MODULES="${FILTERED_MODULES},$mod"
|
||
fi
|
||
done
|
||
|
||
# Deduplicate
|
||
COMBINED_MODULES=$(echo "$FILTERED_MODULES" | tr ',' '\n' | awk '!seen[$0]++' | paste -sd, -)
|
||
|
||
# Get current PHP-CLI version
|
||
local CURRENT_PHP=""
|
||
CURRENT_PHP=$(is_tool_installed "php" 2>/dev/null) || true
|
||
|
||
# Remove conflicting PHP version before pinning
|
||
if [[ -n "$CURRENT_PHP" && "$CURRENT_PHP" != "$PHP_VERSION" ]]; then
|
||
msg_info "Removing conflicting PHP ${CURRENT_PHP} (need ${PHP_VERSION})"
|
||
stop_all_services "php.*-fpm"
|
||
$STD apt purge -y "php*" 2>/dev/null || true
|
||
$STD apt autoremove -y 2>/dev/null || true
|
||
fi
|
||
|
||
# NOW create pinning for the desired version
|
||
mkdir -p /etc/apt/preferences.d
|
||
cat <<EOF >/etc/apt/preferences.d/php-pin
|
||
Package: php${PHP_VERSION}*
|
||
Pin: version ${PHP_VERSION}.*
|
||
Pin-Priority: 1001
|
||
|
||
Package: php[0-9].*
|
||
Pin: release o=packages.sury.org-php
|
||
Pin-Priority: -1
|
||
EOF
|
||
|
||
# Setup repository
|
||
prepare_repository_setup "php" "deb.sury.org-php" || {
|
||
msg_error "Failed to prepare PHP repository"
|
||
return 100
|
||
}
|
||
|
||
# Use different repository based on OS
|
||
if [[ "$DISTRO_ID" == "ubuntu" ]]; then
|
||
# Ubuntu: Use ondrej/php PPA
|
||
msg_info "Adding ondrej/php PPA for Ubuntu"
|
||
$STD apt install -y software-properties-common || {
|
||
msg_error "Failed to install software-properties-common"
|
||
return 100
|
||
}
|
||
# Don't use $STD for add-apt-repository as it uses background processes
|
||
add-apt-repository -y ppa:ondrej/php >>"$(get_active_logfile)" 2>&1
|
||
else
|
||
# Debian: Use Sury repository
|
||
manage_tool_repository "php" "$PHP_VERSION" "" "https://packages.sury.org/debsuryorg-archive-keyring.deb" || {
|
||
msg_error "Failed to setup PHP repository"
|
||
return 100
|
||
}
|
||
fi
|
||
ensure_apt_working || return 100
|
||
$STD apt update || {
|
||
msg_warn "apt update failed after PHP repository setup"
|
||
}
|
||
|
||
# Get available PHP version from repository
|
||
local AVAILABLE_PHP_VERSION=""
|
||
AVAILABLE_PHP_VERSION=$(apt-cache show "php${PHP_VERSION}" 2>/dev/null | grep -m1 "^Version:" | awk '{print $2}' 2>/dev/null | cut -d- -f1 || true)
|
||
|
||
if [[ -z "$AVAILABLE_PHP_VERSION" ]]; then
|
||
msg_error "PHP ${PHP_VERSION} not found in configured repositories"
|
||
return 100
|
||
fi
|
||
|
||
# Build module list - verify each package exists before adding
|
||
local MODULE_LIST="php${PHP_VERSION}"
|
||
local SKIPPED_MODULES=""
|
||
|
||
IFS=',' read -ra MODULES <<<"$COMBINED_MODULES"
|
||
for mod in "${MODULES[@]}"; do
|
||
mod=$(echo "$mod" | tr -d '[:space:]')
|
||
[[ -z "$mod" ]] && continue
|
||
|
||
local pkg_name="php${PHP_VERSION}-${mod}"
|
||
|
||
# Check if package exists in repository
|
||
if apt-cache show "$pkg_name" &>/dev/null; then
|
||
MODULE_LIST+=" $pkg_name"
|
||
else
|
||
# Package doesn't exist - could be built-in or renamed
|
||
if [[ -z "$SKIPPED_MODULES" ]]; then
|
||
SKIPPED_MODULES="$mod"
|
||
else
|
||
SKIPPED_MODULES="${SKIPPED_MODULES}, $mod"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# Log skipped modules (informational, not an error)
|
||
if [[ -n "$SKIPPED_MODULES" ]]; then
|
||
msg_info "Skipping unavailable/built-in modules: $SKIPPED_MODULES"
|
||
fi
|
||
|
||
if [[ "$PHP_FPM" == "YES" ]]; then
|
||
if apt-cache show "php${PHP_VERSION}-fpm" &>/dev/null; then
|
||
MODULE_LIST+=" php${PHP_VERSION}-fpm"
|
||
else
|
||
msg_warn "php${PHP_VERSION}-fpm not available"
|
||
fi
|
||
# Create systemd override for PHP-FPM to fix runtime directory issues in LXC containers
|
||
mkdir -p /etc/systemd/system/php${PHP_VERSION}-fpm.service.d/
|
||
cat <<EOF >/etc/systemd/system/php${PHP_VERSION}-fpm.service.d/override.conf
|
||
[Service]
|
||
RuntimeDirectory=php
|
||
RuntimeDirectoryMode=0755
|
||
EOF
|
||
$STD systemctl daemon-reload
|
||
fi
|
||
|
||
# install apache2 with PHP support if requested
|
||
if [[ "$PHP_APACHE" == "YES" ]]; then
|
||
if ! dpkg -l 2>/dev/null | grep -q "libapache2-mod-php${PHP_VERSION}"; then
|
||
msg_info "Installing Apache with PHP ${PHP_VERSION} module"
|
||
install_packages_with_retry "apache2" || {
|
||
msg_error "Failed to install Apache"
|
||
return 100
|
||
}
|
||
install_packages_with_retry "libapache2-mod-php${PHP_VERSION}" || {
|
||
msg_warn "Failed to install libapache2-mod-php${PHP_VERSION}, continuing without Apache module"
|
||
}
|
||
fi
|
||
fi
|
||
|
||
# Install PHP packages (pinning via preferences.d ensures correct version)
|
||
msg_info "Installing PHP ${PHP_VERSION} packages"
|
||
|
||
# First attempt: Install all verified packages at once
|
||
if ! $STD apt install -y $MODULE_LIST 2>/dev/null; then
|
||
msg_warn "Bulk installation failed, attempting individual installation"
|
||
|
||
# Install main package first (critical)
|
||
if ! $STD apt install -y "php${PHP_VERSION}" 2>/dev/null; then
|
||
msg_error "Failed to install php${PHP_VERSION}"
|
||
return 100
|
||
fi
|
||
|
||
# Try to install Apache module individually if requested
|
||
if [[ "$PHP_APACHE" == "YES" ]]; then
|
||
$STD apt install -y "libapache2-mod-php${PHP_VERSION}" 2>/dev/null || {
|
||
msg_warn "Could not install libapache2-mod-php${PHP_VERSION}"
|
||
}
|
||
fi
|
||
|
||
# Try to install each package individually
|
||
for pkg in $MODULE_LIST; do
|
||
[[ "$pkg" == "php${PHP_VERSION}" ]] && continue # Already installed
|
||
$STD apt install -y "$pkg" 2>/dev/null || {
|
||
msg_warn "Could not install $pkg - continuing without it"
|
||
}
|
||
done
|
||
fi
|
||
cache_installed_version "php" "$PHP_VERSION"
|
||
|
||
# Patch all relevant php.ini files
|
||
local PHP_INI_PATHS=("/etc/php/${PHP_VERSION}/cli/php.ini")
|
||
[[ "$PHP_FPM" == "YES" ]] && PHP_INI_PATHS+=("/etc/php/${PHP_VERSION}/fpm/php.ini")
|
||
[[ "$PHP_APACHE" == "YES" ]] && PHP_INI_PATHS+=("/etc/php/${PHP_VERSION}/apache2/php.ini")
|
||
for ini in "${PHP_INI_PATHS[@]}"; do
|
||
if [[ -f "$ini" ]]; then
|
||
$STD sed -i "s|^memory_limit = .*|memory_limit = ${PHP_MEMORY_LIMIT}|" "$ini"
|
||
$STD sed -i "s|^upload_max_filesize = .*|upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE}|" "$ini"
|
||
$STD sed -i "s|^post_max_size = .*|post_max_size = ${PHP_POST_MAX_SIZE}|" "$ini"
|
||
$STD sed -i "s|^max_execution_time = .*|max_execution_time = ${PHP_MAX_EXECUTION_TIME}|" "$ini"
|
||
fi
|
||
done
|
||
|
||
# Patch Apache configuration if needed
|
||
if [[ "$PHP_APACHE" == "YES" ]]; then
|
||
for mod in $(ls /etc/apache2/mods-enabled/ 2>/dev/null | grep -E '^php[0-9]\.[0-9]\.conf$' | sed 's/\.conf//'); do
|
||
if [[ "$mod" != "php${PHP_VERSION}" ]]; then
|
||
$STD a2dismod "$mod" || true
|
||
fi
|
||
done
|
||
$STD a2enmod mpm_prefork
|
||
$STD a2enmod "php${PHP_VERSION}"
|
||
safe_service_restart apache2 || true
|
||
fi
|
||
|
||
# Enable and restart PHP-FPM if requested
|
||
if [[ "$PHP_FPM" == "YES" ]]; then
|
||
if systemctl list-unit-files | grep -q "php${PHP_VERSION}-fpm.service"; then
|
||
$STD systemctl enable php${PHP_VERSION}-fpm
|
||
safe_service_restart php${PHP_VERSION}-fpm
|
||
fi
|
||
fi
|
||
|
||
# Verify PHP installation - critical check
|
||
if ! command -v php >/dev/null 2>&1; then
|
||
msg_error "PHP installation verification failed - php command not found"
|
||
return 127
|
||
fi
|
||
|
||
local INSTALLED_VERSION=$(php -v 2>/dev/null | awk '/^PHP/{print $2}' | cut -d. -f1,2)
|
||
|
||
if [[ "$INSTALLED_VERSION" != "$PHP_VERSION" ]]; then
|
||
msg_error "PHP version mismatch: requested ${PHP_VERSION} but got ${INSTALLED_VERSION}"
|
||
msg_error "This indicates a critical package installation issue"
|
||
# Don't cache wrong version
|
||
return 127
|
||
fi
|
||
|
||
cache_installed_version "php" "$INSTALLED_VERSION"
|
||
msg_ok "Setup PHP ${INSTALLED_VERSION}"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or upgrades PostgreSQL and optional extensions/modules.
|
||
#
|
||
# Description:
|
||
# - By default uses distro repository (Debian/Ubuntu apt) for stability
|
||
# - Optionally uses official PGDG repository for specific versions
|
||
# - Detects existing PostgreSQL version
|
||
# - Dumps all databases before upgrade
|
||
# - Installs optional PG_MODULES (e.g. postgis, contrib, cron)
|
||
# - Restores dumped data post-upgrade
|
||
#
|
||
# Variables:
|
||
# USE_PGDG_REPO - Use official PGDG repository (default: true)
|
||
# Set to "false" to use distro packages instead
|
||
# PG_VERSION - Major PostgreSQL version (e.g. 15, 16) (default: 16)
|
||
# PG_MODULES - Comma-separated list of modules (e.g. "postgis,contrib,cron")
|
||
#
|
||
# Examples:
|
||
# setup_postgresql # Uses PGDG repo, PG 16
|
||
# PG_VERSION="17" setup_postgresql # Specific version from PGDG
|
||
# USE_PGDG_REPO=false setup_postgresql # Uses distro package instead
|
||
# PG_VERSION="17" PG_MODULES="cron" setup_postgresql # With pg_cron module
|
||
# ------------------------------------------------------------------------------
|
||
|
||
# Internal helper: Configure shared_preload_libraries for pg_cron
|
||
_configure_pg_cron_preload() {
|
||
local modules="${1:-}"
|
||
[[ -z "$modules" ]] && return 0
|
||
if [[ ",$modules," == *",cron,"* ]]; then
|
||
local current_libs
|
||
current_libs=$(sudo -u postgres psql -tAc "SHOW shared_preload_libraries;" 2>/dev/null || echo "")
|
||
if [[ "$current_libs" != *"pg_cron"* ]]; then
|
||
local new_libs="${current_libs:+${current_libs},}pg_cron"
|
||
$STD sudo -u postgres psql -c "ALTER SYSTEM SET shared_preload_libraries = '${new_libs}';"
|
||
$STD systemctl restart postgresql
|
||
fi
|
||
fi
|
||
}
|
||
|
||
setup_postgresql() {
|
||
local PG_VERSION="${PG_VERSION:-16}"
|
||
local PG_MODULES="${PG_MODULES:-}"
|
||
local USE_PGDG_REPO="${USE_PGDG_REPO:-true}"
|
||
local DISTRO_ID DISTRO_CODENAME
|
||
DISTRO_ID=$(get_os_info id)
|
||
DISTRO_CODENAME=$(get_os_info codename)
|
||
|
||
# Ensure non-interactive mode for all apt operations
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export NEEDRESTART_MODE=a
|
||
export NEEDRESTART_SUSPEND=1
|
||
|
||
# Get currently installed version
|
||
local CURRENT_PG_VERSION=""
|
||
if command -v psql >/dev/null; then
|
||
CURRENT_PG_VERSION="$(psql -V 2>/dev/null | awk '{print $3}' | cut -d. -f1)"
|
||
fi
|
||
|
||
# Scenario 1: Use distro repository (default, most stable)
|
||
if [[ "$USE_PGDG_REPO" != "true" && "$USE_PGDG_REPO" != "TRUE" && "$USE_PGDG_REPO" != "1" ]]; then
|
||
msg_info "Setup PostgreSQL (distro package)"
|
||
|
||
# If already installed, just update
|
||
if [[ -n "$CURRENT_PG_VERSION" ]]; then
|
||
msg_info "Update PostgreSQL $CURRENT_PG_VERSION"
|
||
ensure_apt_working || return 100
|
||
upgrade_packages_with_retry "postgresql" "postgresql-client" || true
|
||
cache_installed_version "postgresql" "$CURRENT_PG_VERSION"
|
||
msg_ok "Update PostgreSQL $CURRENT_PG_VERSION"
|
||
|
||
# Still install modules if specified
|
||
if [[ -n "$PG_MODULES" ]]; then
|
||
IFS=',' read -ra MODULES <<<"$PG_MODULES"
|
||
for module in "${MODULES[@]}"; do
|
||
$STD apt install -y "postgresql-${CURRENT_PG_VERSION}-${module}" 2>/dev/null ||
|
||
msg_warn "Optional PostgreSQL module '${module}' not available for PG ${CURRENT_PG_VERSION} on $(get_os_info codename) — skipping"
|
||
done
|
||
fi
|
||
_configure_pg_cron_preload "$PG_MODULES"
|
||
return 0
|
||
fi
|
||
|
||
# Fresh install from distro repo
|
||
ensure_apt_working || return 100
|
||
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
install_packages_with_retry "postgresql" "postgresql-client" || {
|
||
msg_error "Failed to install PostgreSQL from distro repository"
|
||
return 100
|
||
}
|
||
|
||
# Get installed version
|
||
local INSTALLED_VERSION=""
|
||
if command -v psql >/dev/null; then
|
||
INSTALLED_VERSION="$(psql -V 2>/dev/null | awk '{print $3}' | cut -d. -f1)"
|
||
fi
|
||
|
||
$STD systemctl enable --now postgresql 2>/dev/null || true
|
||
|
||
# Add PostgreSQL binaries to PATH
|
||
if [[ -n "$INSTALLED_VERSION" ]] && ! grep -q '/usr/lib/postgresql' /etc/environment 2>/dev/null; then
|
||
echo 'PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/'"${INSTALLED_VERSION}"'/bin"' >/etc/environment
|
||
fi
|
||
|
||
cache_installed_version "postgresql" "${INSTALLED_VERSION:-distro}"
|
||
msg_ok "Setup PostgreSQL ${INSTALLED_VERSION:-from distro}"
|
||
|
||
# Install optional modules
|
||
if [[ -n "$PG_MODULES" && -n "$INSTALLED_VERSION" ]]; then
|
||
IFS=',' read -ra MODULES <<<"$PG_MODULES"
|
||
for module in "${MODULES[@]}"; do
|
||
$STD apt install -y "postgresql-${INSTALLED_VERSION}-${module}" 2>/dev/null ||
|
||
msg_warn "Optional PostgreSQL module '${module}' not available for PG ${INSTALLED_VERSION} on $(get_os_info codename) — skipping"
|
||
done
|
||
fi
|
||
_configure_pg_cron_preload "$PG_MODULES"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Use official PGDG repository (USE_PGDG_REPO=true)
|
||
# Scenario 2a: Already at correct version
|
||
if [[ "$CURRENT_PG_VERSION" == "$PG_VERSION" ]]; then
|
||
msg_info "Update PostgreSQL $PG_VERSION"
|
||
ensure_apt_working || return 100
|
||
|
||
# Perform upgrade with retry logic (non-fatal if fails)
|
||
upgrade_packages_with_retry "postgresql-${PG_VERSION}" "postgresql-client-${PG_VERSION}" 2>/dev/null || true
|
||
cache_installed_version "postgresql" "$PG_VERSION"
|
||
msg_ok "Update PostgreSQL $PG_VERSION"
|
||
|
||
# Still install modules if specified
|
||
if [[ -n "$PG_MODULES" ]]; then
|
||
IFS=',' read -ra MODULES <<<"$PG_MODULES"
|
||
for module in "${MODULES[@]}"; do
|
||
$STD apt install -y "postgresql-${PG_VERSION}-${module}" 2>/dev/null ||
|
||
msg_warn "Optional PostgreSQL module '${module}' not available for PG ${PG_VERSION} on $(get_os_info codename) — skipping"
|
||
done
|
||
fi
|
||
_configure_pg_cron_preload "$PG_MODULES"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version - backup, remove old, install new
|
||
if [[ -n "$CURRENT_PG_VERSION" ]]; then
|
||
msg_info "Upgrade PostgreSQL from $CURRENT_PG_VERSION to $PG_VERSION"
|
||
msg_info "Creating backup of PostgreSQL $CURRENT_PG_VERSION databases..."
|
||
local PG_BACKUP_FILE="/var/lib/postgresql/backup_$(date +%F)_v${CURRENT_PG_VERSION}.sql"
|
||
$STD runuser -u postgres -- pg_dumpall >"$PG_BACKUP_FILE" || {
|
||
msg_error "Failed to backup PostgreSQL databases"
|
||
return 150
|
||
}
|
||
$STD systemctl stop postgresql || true
|
||
$STD apt purge -y "postgresql-${CURRENT_PG_VERSION}" "postgresql-client-${CURRENT_PG_VERSION}" 2>/dev/null || true
|
||
else
|
||
msg_info "Setup PostgreSQL $PG_VERSION"
|
||
fi
|
||
|
||
# Scenario 3: Fresh install or after removal - setup repo and install
|
||
prepare_repository_setup "pgdg" "postgresql" || {
|
||
msg_error "Failed to prepare PostgreSQL repository"
|
||
return 100
|
||
}
|
||
|
||
local SUITE
|
||
case "$DISTRO_CODENAME" in
|
||
trixie | forky | sid)
|
||
|
||
if verify_repo_available "https://apt.postgresql.org/pub/repos/apt" "trixie-pgdg"; then
|
||
SUITE="trixie-pgdg"
|
||
|
||
else
|
||
local _distro_pg_ver
|
||
_distro_pg_ver=$(apt-cache show postgresql 2>/dev/null | awk '/^Version:/{print $2; exit}' | grep -oE '^[0-9]+' || true)
|
||
msg_warn "PGDG repository not available for ${DISTRO_CODENAME} — falling back to distro-provided PostgreSQL packages"
|
||
if [[ -n "$_distro_pg_ver" ]]; then
|
||
msg_warn "Distro will install PostgreSQL ${_distro_pg_ver} (not the requested ${PG_VERSION})."
|
||
msg_warn "Any PostgreSQL extension packages (e.g. vchord, pgvector) must be built for PostgreSQL ${_distro_pg_ver} on ${DISTRO_CODENAME}."
|
||
fi
|
||
USE_PGDG_REPO=false setup_postgresql
|
||
return $?
|
||
fi
|
||
|
||
;;
|
||
*)
|
||
SUITE=$(get_fallback_suite "$DISTRO_ID" "$DISTRO_CODENAME" "https://apt.postgresql.org/pub/repos/apt")
|
||
SUITE="${SUITE}-pgdg"
|
||
;;
|
||
esac
|
||
|
||
setup_deb822_repo \
|
||
"pgdg" \
|
||
"https://www.postgresql.org/media/keys/ACCC4CF8.asc" \
|
||
"https://apt.postgresql.org/pub/repos/apt" \
|
||
"$SUITE" \
|
||
"main"
|
||
|
||
if ! $STD apt update; then
|
||
msg_error "APT update failed for PostgreSQL repository"
|
||
return 100
|
||
fi
|
||
|
||
# Install ssl-cert dependency if available
|
||
if apt-cache search "^ssl-cert$" 2>/dev/null | grep -q .; then
|
||
$STD apt install -y ssl-cert 2>/dev/null ||
|
||
msg_warn "Optional ssl-cert package could not be installed — continuing without it"
|
||
fi
|
||
|
||
# Try multiple PostgreSQL package patterns with retry logic
|
||
local pg_install_success=false
|
||
|
||
if apt-cache search "^postgresql-${PG_VERSION}$" 2>/dev/null | grep -q . &&
|
||
install_packages_with_retry "postgresql-${PG_VERSION}" "postgresql-client-${PG_VERSION}"; then
|
||
pg_install_success=true
|
||
fi
|
||
|
||
if [[ "$pg_install_success" == false ]] &&
|
||
apt-cache search "^postgresql-server-${PG_VERSION}$" 2>/dev/null | grep -q . &&
|
||
$STD apt install -y "postgresql-server-${PG_VERSION}" "postgresql-client-${PG_VERSION}" 2>/dev/null; then
|
||
pg_install_success=true
|
||
fi
|
||
|
||
if [[ "$pg_install_success" == false ]] &&
|
||
apt-cache search "^postgresql$" 2>/dev/null | grep -q . &&
|
||
$STD apt install -y postgresql postgresql-client 2>/dev/null; then
|
||
pg_install_success=true
|
||
fi
|
||
|
||
if [[ "$pg_install_success" == false ]]; then
|
||
msg_error "PostgreSQL package not available for suite ${SUITE}"
|
||
return 100
|
||
fi
|
||
|
||
if ! command -v psql >/dev/null 2>&1; then
|
||
msg_error "PostgreSQL installed but psql command not found"
|
||
return 127
|
||
fi
|
||
|
||
# Restore database backup if we upgraded from previous version
|
||
if [[ -n "$CURRENT_PG_VERSION" && -n "${PG_BACKUP_FILE:-}" && -f "${PG_BACKUP_FILE}" ]]; then
|
||
msg_info "Restoring PostgreSQL databases from backup..."
|
||
$STD runuser -u postgres -- psql <"$PG_BACKUP_FILE" 2>/dev/null || {
|
||
msg_warn "Failed to restore database backup - this may be expected for major version upgrades"
|
||
}
|
||
fi
|
||
|
||
$STD systemctl enable --now postgresql 2>/dev/null || {
|
||
msg_warn "Failed to enable/start PostgreSQL service"
|
||
}
|
||
|
||
# Add PostgreSQL binaries to PATH
|
||
if ! grep -q '/usr/lib/postgresql' /etc/environment 2>/dev/null; then
|
||
echo 'PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/'"${PG_VERSION}"'/bin"' >/etc/environment
|
||
fi
|
||
|
||
cache_installed_version "postgresql" "$PG_VERSION"
|
||
msg_ok "Setup PostgreSQL $PG_VERSION"
|
||
|
||
# Install optional modules
|
||
if [[ -n "$PG_MODULES" ]]; then
|
||
IFS=',' read -ra MODULES <<<"$PG_MODULES"
|
||
for module in "${MODULES[@]}"; do
|
||
$STD apt install -y "postgresql-${PG_VERSION}-${module}" 2>/dev/null || {
|
||
msg_warn "Failed to install PostgreSQL module: ${module}"
|
||
}
|
||
done
|
||
fi
|
||
_configure_pg_cron_preload "$PG_MODULES"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Creates PostgreSQL database with user and optional extensions
|
||
#
|
||
# Description:
|
||
# - Creates PostgreSQL role with login and password
|
||
# - Creates database with UTF8 encoding and template0
|
||
# - Installs optional extensions (postgis, pgvector, etc.)
|
||
# - Configures ALTER ROLE settings for Django/Rails compatibility
|
||
# - Saves credentials to file
|
||
# - Exports variables for use in calling script
|
||
#
|
||
# Usage:
|
||
# PG_DB_NAME="myapp_db" PG_DB_USER="myapp_user" setup_postgresql_db
|
||
# PG_DB_NAME="immich" PG_DB_USER="immich" PG_DB_EXTENSIONS="pgvector" setup_postgresql_db
|
||
# PG_DB_NAME="ghostfolio" PG_DB_USER="ghostfolio" PG_DB_GRANT_SUPERUSER="true" setup_postgresql_db
|
||
# PG_DB_NAME="adventurelog" PG_DB_USER="adventurelog" PG_DB_EXTENSIONS="postgis" setup_postgresql_db
|
||
# PG_DB_NAME="splitpro" PG_DB_USER="splitpro" PG_DB_EXTENSIONS="pg_cron" setup_postgresql_db
|
||
#
|
||
# Variables:
|
||
# PG_DB_NAME - Database name (required)
|
||
# PG_DB_USER - Database user (required)
|
||
# PG_DB_PASS - Database password (optional, auto-generated if empty)
|
||
# PG_DB_EXTENSIONS - Comma-separated list of extensions (optional, e.g. "postgis,pgvector")
|
||
# PG_DB_GRANT_SUPERUSER - Grant SUPERUSER privilege (optional, "true" to enable, security risk!)
|
||
# PG_DB_SCHEMA_PERMS - Grant schema-level permissions (optional, "true" to enable)
|
||
# PG_DB_SKIP_ALTER_ROLE - Skip ALTER ROLE settings (optional, "true" to skip)
|
||
# PG_DB_CREDS_FILE - Credentials file path (optional, default: ~/${APPLICATION}.creds)
|
||
#
|
||
# Exports:
|
||
# PG_DB_NAME, PG_DB_USER, PG_DB_PASS - For use in calling script
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_postgresql_db() {
|
||
# Validation
|
||
if [[ -z "${PG_DB_NAME:-}" || -z "${PG_DB_USER:-}" ]]; then
|
||
msg_error "PG_DB_NAME and PG_DB_USER must be set before calling setup_postgresql_db"
|
||
return 65
|
||
fi
|
||
|
||
# Generate password if not provided
|
||
if [[ -z "${PG_DB_PASS:-}" ]]; then
|
||
PG_DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13)
|
||
fi
|
||
|
||
msg_info "Setting up PostgreSQL Database"
|
||
$STD sudo -u postgres psql -c "CREATE ROLE $PG_DB_USER WITH LOGIN PASSWORD '$PG_DB_PASS';"
|
||
$STD sudo -u postgres psql -c "CREATE DATABASE $PG_DB_NAME WITH OWNER $PG_DB_USER ENCODING 'UTF8' TEMPLATE template0;"
|
||
|
||
# Configure pg_cron database BEFORE creating the extension (must be set before pg_cron loads)
|
||
if [[ -n "${PG_DB_EXTENSIONS:-}" ]] && [[ ",${PG_DB_EXTENSIONS//[[:space:]]/}," == *",pg_cron,"* ]]; then
|
||
$STD sudo -u postgres psql -c "ALTER SYSTEM SET cron.database_name = '${PG_DB_NAME}';"
|
||
$STD sudo -u postgres psql -c "ALTER SYSTEM SET cron.timezone = 'UTC';"
|
||
$STD systemctl restart postgresql
|
||
fi
|
||
|
||
# Install extensions (comma-separated)
|
||
if [[ -n "${PG_DB_EXTENSIONS:-}" ]]; then
|
||
IFS=',' read -ra EXT_LIST <<<"${PG_DB_EXTENSIONS:-}"
|
||
for ext in "${EXT_LIST[@]}"; do
|
||
ext=$(echo "$ext" | xargs) # Trim whitespace
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS \"$ext\";"
|
||
done
|
||
fi
|
||
|
||
# Grant pg_cron schema permissions to DB user
|
||
if [[ -n "${PG_DB_EXTENSIONS:-}" ]] && [[ ",${PG_DB_EXTENSIONS//[[:space:]]/}," == *",pg_cron,"* ]]; then
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "GRANT USAGE ON SCHEMA cron TO ${PG_DB_USER};"
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "GRANT ALL ON ALL TABLES IN SCHEMA cron TO ${PG_DB_USER};"
|
||
fi
|
||
|
||
# ALTER ROLE settings for Django/Rails compatibility (unless skipped)
|
||
if [[ "${PG_DB_SKIP_ALTER_ROLE:-}" != "true" ]]; then
|
||
$STD sudo -u postgres psql -c "ALTER ROLE $PG_DB_USER SET client_encoding TO 'utf8';"
|
||
$STD sudo -u postgres psql -c "ALTER ROLE $PG_DB_USER SET default_transaction_isolation TO 'read committed';"
|
||
$STD sudo -u postgres psql -c "ALTER ROLE $PG_DB_USER SET timezone TO 'UTC';"
|
||
fi
|
||
|
||
# Schema permissions (if requested)
|
||
if [[ "${PG_DB_SCHEMA_PERMS:-}" == "true" ]]; then
|
||
$STD sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $PG_DB_NAME TO $PG_DB_USER;"
|
||
$STD sudo -u postgres psql -c "ALTER USER $PG_DB_USER CREATEDB;"
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "GRANT ALL ON SCHEMA public TO $PG_DB_USER;"
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "GRANT CREATE ON SCHEMA public TO $PG_DB_USER;"
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $PG_DB_USER;"
|
||
$STD sudo -u postgres psql -d "$PG_DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $PG_DB_USER;"
|
||
fi
|
||
|
||
# Superuser grant (if requested - WARNING!)
|
||
if [[ "${PG_DB_GRANT_SUPERUSER:-}" == "true" ]]; then
|
||
msg_warn "Granting SUPERUSER privilege (security risk!)"
|
||
$STD sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $PG_DB_NAME to $PG_DB_USER;"
|
||
$STD sudo -u postgres psql -c "ALTER USER $PG_DB_USER WITH SUPERUSER;"
|
||
fi
|
||
|
||
# Save credentials
|
||
local app_name="${APPLICATION,,}"
|
||
local CREDS_FILE="${PG_DB_CREDS_FILE:-${HOME}/${app_name}.creds}"
|
||
{
|
||
echo "PostgreSQL Credentials"
|
||
echo "Database: $PG_DB_NAME"
|
||
echo "User: $PG_DB_USER"
|
||
echo "Password: $PG_DB_PASS"
|
||
} >>"$CREDS_FILE"
|
||
|
||
msg_ok "Set up PostgreSQL Database"
|
||
|
||
# Export for use in calling script
|
||
export PG_DB_NAME
|
||
export PG_DB_USER
|
||
export PG_DB_PASS
|
||
}
|
||
# ------------------------------------------------------------------------------
|
||
# Installs rbenv and ruby-build, installs Ruby and optionally Rails.
|
||
#
|
||
# Description:
|
||
# - Downloads rbenv and ruby-build from GitHub
|
||
# - Compiles and installs target Ruby version
|
||
# - Optionally installs Rails via gem
|
||
#
|
||
# Variables:
|
||
# RUBY_VERSION - Ruby version to install (default: 3.4.4)
|
||
# RUBY_INSTALL_RAILS - true/false to install Rails (default: true)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_ruby() {
|
||
local RUBY_VERSION="${RUBY_VERSION:-3.4.4}"
|
||
local RUBY_INSTALL_RAILS="${RUBY_INSTALL_RAILS:-true}"
|
||
local RBENV_DIR="$HOME/.rbenv"
|
||
local RBENV_BIN="$RBENV_DIR/bin/rbenv"
|
||
local PROFILE_FILE="$HOME/.profile"
|
||
local TMP_DIR=$(mktemp -d)
|
||
|
||
# Get currently installed Ruby version
|
||
local CURRENT_RUBY_VERSION=""
|
||
if [[ -x "$RBENV_BIN" ]]; then
|
||
CURRENT_RUBY_VERSION=$("$RBENV_BIN" global 2>/dev/null || echo "")
|
||
fi
|
||
|
||
# Scenario 1: Already at correct Ruby version
|
||
if [[ "$CURRENT_RUBY_VERSION" == "$RUBY_VERSION" ]]; then
|
||
msg_info "Update Ruby $RUBY_VERSION"
|
||
cache_installed_version "ruby" "$RUBY_VERSION"
|
||
msg_ok "Update Ruby $RUBY_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: Different version - reinstall
|
||
if [[ -n "$CURRENT_RUBY_VERSION" ]]; then
|
||
msg_info "Upgrade Ruby from $CURRENT_RUBY_VERSION to $RUBY_VERSION"
|
||
else
|
||
msg_info "Setup Ruby $RUBY_VERSION"
|
||
fi
|
||
|
||
ensure_apt_working || return 100
|
||
|
||
# Install build dependencies with fallbacks
|
||
local ruby_deps=()
|
||
local dep_variations=(
|
||
"jq"
|
||
"autoconf"
|
||
"patch"
|
||
"build-essential"
|
||
"libssl-dev"
|
||
"libyaml-dev"
|
||
"libreadline-dev|libreadline6-dev"
|
||
"zlib1g-dev"
|
||
"libgmp-dev"
|
||
"libncurses-dev|libncurses5-dev"
|
||
"libffi-dev"
|
||
"libgdbm-dev"
|
||
"libdb-dev"
|
||
"uuid-dev"
|
||
)
|
||
|
||
for dep_pattern in "${dep_variations[@]}"; do
|
||
if [[ "$dep_pattern" == *"|"* ]]; then
|
||
IFS='|' read -ra variations <<<"$dep_pattern"
|
||
for var in "${variations[@]}"; do
|
||
if apt-cache search "^${var}$" 2>/dev/null | grep -q .; then
|
||
ruby_deps+=("$var")
|
||
break
|
||
fi
|
||
done
|
||
else
|
||
if apt-cache search "^${dep_pattern}$" 2>/dev/null | grep -q .; then
|
||
ruby_deps+=("$dep_pattern")
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [[ ${#ruby_deps[@]} -gt 0 ]]; then
|
||
$STD apt install -y "${ruby_deps[@]}" 2>/dev/null ||
|
||
msg_warn "Some Ruby build dependencies could not be installed — compilation may fail"
|
||
else
|
||
msg_error "No Ruby build dependencies available"
|
||
rm -rf "$TMP_DIR"
|
||
return 100
|
||
fi
|
||
|
||
# Download and build rbenv if needed
|
||
if [[ ! -x "$RBENV_BIN" ]]; then
|
||
local RBENV_RELEASE
|
||
RBENV_RELEASE=$(get_latest_github_release "rbenv/rbenv") || {
|
||
msg_error "Failed to fetch latest rbenv version from GitHub"
|
||
rm -rf "$TMP_DIR"
|
||
return 7
|
||
}
|
||
|
||
if ! curl_with_retry "https://github.com/rbenv/rbenv/archive/refs/tags/v${RBENV_RELEASE}.tar.gz" "$TMP_DIR/rbenv.tar.gz"; then
|
||
msg_error "Failed to download rbenv"
|
||
msg_error "Hint: Check connectivity to github.com/rbenv/rbenv"
|
||
rm -rf "$TMP_DIR"
|
||
return 7
|
||
fi
|
||
|
||
tar -xzf "$TMP_DIR/rbenv.tar.gz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract rbenv"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
mkdir -p "$RBENV_DIR"
|
||
cp -r "$TMP_DIR/rbenv-${RBENV_RELEASE}/." "$RBENV_DIR/"
|
||
(cd "$RBENV_DIR" && src/configure && $STD make -C src) || {
|
||
msg_error "Failed to build rbenv"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
|
||
# Setup profile
|
||
if ! grep -q 'rbenv init' "$PROFILE_FILE" 2>/dev/null; then
|
||
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >>"$PROFILE_FILE"
|
||
echo 'eval "$(rbenv init -)"' >>"$PROFILE_FILE"
|
||
fi
|
||
fi
|
||
|
||
# Install ruby-build plugin
|
||
if [[ ! -d "$RBENV_DIR/plugins/ruby-build" ]]; then
|
||
local RUBY_BUILD_RELEASE
|
||
RUBY_BUILD_RELEASE=$(get_latest_github_release "rbenv/ruby-build") || {
|
||
msg_error "Failed to fetch latest ruby-build version from GitHub"
|
||
rm -rf "$TMP_DIR"
|
||
return 7
|
||
}
|
||
|
||
if ! curl_with_retry "https://github.com/rbenv/ruby-build/archive/refs/tags/v${RUBY_BUILD_RELEASE}.tar.gz" "$TMP_DIR/ruby-build.tar.gz"; then
|
||
msg_error "Failed to download ruby-build"
|
||
msg_error "Hint: Check connectivity to github.com/rbenv/ruby-build"
|
||
rm -rf "$TMP_DIR"
|
||
return 7
|
||
fi
|
||
|
||
tar -xzf "$TMP_DIR/ruby-build.tar.gz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract ruby-build"
|
||
rm -rf "$TMP_DIR"
|
||
return 251
|
||
}
|
||
|
||
mkdir -p "$RBENV_DIR/plugins/ruby-build"
|
||
cp -r "$TMP_DIR/ruby-build-${RUBY_BUILD_RELEASE}/." "$RBENV_DIR/plugins/ruby-build/"
|
||
fi
|
||
|
||
# Setup PATH and install Ruby version
|
||
export PATH="$RBENV_DIR/bin:$PATH"
|
||
eval "$("$RBENV_BIN" init - bash)" 2>/dev/null || true
|
||
|
||
if ! "$RBENV_BIN" versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then
|
||
$STD "$RBENV_BIN" install "$RUBY_VERSION" || {
|
||
msg_error "Failed to install Ruby $RUBY_VERSION"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
fi
|
||
|
||
"$RBENV_BIN" global "$RUBY_VERSION" || {
|
||
msg_error "Failed to set Ruby $RUBY_VERSION as global version"
|
||
rm -rf "$TMP_DIR"
|
||
return 150
|
||
}
|
||
|
||
hash -r
|
||
|
||
# Install Rails if requested
|
||
if [[ "$RUBY_INSTALL_RAILS" == "true" ]]; then
|
||
$STD gem install rails || {
|
||
msg_warn "Failed to install Rails - Ruby installation successful"
|
||
}
|
||
fi
|
||
|
||
rm -rf "$TMP_DIR"
|
||
cache_installed_version "ruby" "$RUBY_VERSION"
|
||
msg_ok "Setup Ruby $RUBY_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs Rust toolchain and optional global crates via cargo.
|
||
#
|
||
# Description:
|
||
# - Installs rustup (if missing)
|
||
# - Installs or updates desired Rust toolchain (stable, nightly, or versioned)
|
||
# - Installs or updates specified global crates using `cargo install`
|
||
#
|
||
# Notes:
|
||
# - Skips crate install if exact version is already present
|
||
# - Updates crate if newer version or different version is requested
|
||
#
|
||
# Variables:
|
||
# RUST_TOOLCHAIN - Rust toolchain to install (default: stable)
|
||
# RUST_PROFILE - Rust installation profile (default: default, e.g. minimal)
|
||
# RUST_CRATES - Comma-separated list of crates (e.g. "cargo-edit,wasm-pack@0.12.1")
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_rust() {
|
||
local RUST_TOOLCHAIN="${RUST_TOOLCHAIN:-stable}"
|
||
local RUST_PROFILE="${RUST_PROFILE:-default}"
|
||
local RUST_CRATES="${RUST_CRATES:-}"
|
||
local CARGO_BIN="${HOME}/.cargo/bin"
|
||
|
||
# Get currently installed version
|
||
local CURRENT_VERSION=""
|
||
if command -v rustc &>/dev/null; then
|
||
CURRENT_VERSION=$(rustc --version 2>/dev/null | awk '{print $2}' 2>/dev/null) || true
|
||
fi
|
||
|
||
# Scenario 1: Rustup not installed - fresh install
|
||
if ! command -v rustup &>/dev/null; then
|
||
msg_info "Setup Rust ($RUST_TOOLCHAIN, profile: $RUST_PROFILE)"
|
||
curl -fsSL https://sh.rustup.rs | $STD sh -s -- -y --profile "$RUST_PROFILE" --default-toolchain "$RUST_TOOLCHAIN" || {
|
||
msg_error "Failed to install Rust"
|
||
msg_error "Hint: Check connectivity to sh.rustup.rs and static.rust-lang.org"
|
||
return 7
|
||
}
|
||
export PATH="$CARGO_BIN:$PATH"
|
||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >>"$HOME/.profile"
|
||
|
||
# Verify installation
|
||
if ! command -v rustc >/dev/null 2>&1; then
|
||
msg_error "Rust binary not found after installation"
|
||
return 127
|
||
fi
|
||
|
||
local RUST_VERSION
|
||
RUST_VERSION=$(rustc --version 2>/dev/null | awk '{print $2}' 2>/dev/null) || true
|
||
if [[ -z "$RUST_VERSION" ]]; then
|
||
msg_error "Failed to determine Rust version"
|
||
return 250
|
||
fi
|
||
|
||
cache_installed_version "rust" "$RUST_VERSION"
|
||
msg_ok "Setup Rust $RUST_VERSION"
|
||
else
|
||
# Scenario 2: Rustup already installed - update/maintain
|
||
msg_info "Update Rust ($RUST_TOOLCHAIN)"
|
||
|
||
# Ensure default toolchain is set
|
||
$STD rustup default "$RUST_TOOLCHAIN" 2>/dev/null || {
|
||
# If default fails, install the toolchain first
|
||
$STD rustup install "$RUST_TOOLCHAIN" || {
|
||
msg_error "Failed to install Rust toolchain $RUST_TOOLCHAIN"
|
||
return 150
|
||
}
|
||
$STD rustup default "$RUST_TOOLCHAIN" || {
|
||
msg_error "Failed to set default Rust toolchain"
|
||
return 150
|
||
}
|
||
}
|
||
|
||
# Update to latest patch version
|
||
$STD rustup update "$RUST_TOOLCHAIN" </dev/null || {
|
||
msg_warn "Rust toolchain update had issues"
|
||
}
|
||
|
||
# Ensure PATH is updated for current shell session
|
||
export PATH="$CARGO_BIN:$PATH"
|
||
|
||
local RUST_VERSION
|
||
RUST_VERSION=$(rustc --version 2>/dev/null | awk '{print $2}' 2>/dev/null) || true
|
||
if [[ -z "$RUST_VERSION" ]]; then
|
||
msg_error "Failed to determine Rust version after update"
|
||
return 250
|
||
fi
|
||
|
||
cache_installed_version "rust" "$RUST_VERSION"
|
||
msg_ok "Update Rust $RUST_VERSION"
|
||
fi
|
||
|
||
# Install global crates
|
||
if [[ -n "$RUST_CRATES" ]]; then
|
||
msg_info "Processing Rust crates: $RUST_CRATES"
|
||
IFS=',' read -ra CRATES <<<"$RUST_CRATES"
|
||
for crate in "${CRATES[@]}"; do
|
||
crate=$(echo "$crate" | xargs) # trim whitespace
|
||
[[ -z "$crate" ]] && continue # skip empty entries
|
||
|
||
local NAME VER INSTALLED_VER CRATE_LIST
|
||
if [[ "$crate" == *"@"* ]]; then
|
||
NAME="${crate%@*}"
|
||
VER="${crate##*@}"
|
||
else
|
||
NAME="$crate"
|
||
VER=""
|
||
fi
|
||
|
||
# Get list of installed crates once
|
||
CRATE_LIST=$(cargo install --list 2>/dev/null || echo "")
|
||
|
||
# Check if already installed
|
||
if echo "$CRATE_LIST" | grep -q "^${NAME} "; then
|
||
INSTALLED_VER=$(echo "$CRATE_LIST" | grep "^${NAME} " | head -1 | awk '{print $2}' 2>/dev/null | tr -d 'v:' || echo '')
|
||
|
||
if [[ -n "$VER" && "$VER" != "$INSTALLED_VER" ]]; then
|
||
msg_info "Upgrading $NAME from v$INSTALLED_VER to v$VER"
|
||
$STD cargo install "$NAME" --version "$VER" --force || {
|
||
msg_error "Failed to install $NAME@$VER"
|
||
return 150
|
||
}
|
||
msg_ok "Upgraded $NAME to v$VER"
|
||
elif [[ -z "$VER" ]]; then
|
||
msg_info "Upgrading $NAME to latest"
|
||
$STD cargo install "$NAME" --force || {
|
||
msg_error "Failed to upgrade $NAME"
|
||
return 150
|
||
}
|
||
local NEW_VER=$(cargo install --list 2>/dev/null | grep "^${NAME} " | head -1 | awk '{print $2}' 2>/dev/null | tr -d 'v:' || echo 'unknown')
|
||
msg_ok "Upgraded $NAME to v$NEW_VER"
|
||
else
|
||
msg_ok "$NAME v$INSTALLED_VER already installed"
|
||
fi
|
||
else
|
||
msg_info "Installing $NAME${VER:+@$VER}"
|
||
if [[ -n "$VER" ]]; then
|
||
$STD cargo install "$NAME" --version "$VER" || {
|
||
msg_error "Failed to install $NAME@$VER"
|
||
return 150
|
||
}
|
||
msg_ok "Installed $NAME v$VER"
|
||
else
|
||
$STD cargo install "$NAME" || {
|
||
msg_error "Failed to install $NAME"
|
||
return 150
|
||
}
|
||
local NEW_VER=$(cargo install --list 2>/dev/null | grep "^${NAME} " | head -1 | awk '{print $2}' 2>/dev/null | tr -d 'v:' || echo 'unknown')
|
||
msg_ok "Installed $NAME v$NEW_VER"
|
||
fi
|
||
fi
|
||
done
|
||
msg_ok "Processed Rust crates"
|
||
fi
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or upgrades uv (Python package manager) from GitHub releases.
|
||
# - Downloads platform-specific tarball (no install.sh!)
|
||
# - Extracts uv binary
|
||
# - Places it in /usr/local/bin
|
||
# - Optionally installs a specific Python version via uv
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_uv() {
|
||
local UV_BIN="/usr/local/bin/uv"
|
||
local UVX_BIN="/usr/local/bin/uvx"
|
||
local TMP_DIR=$(mktemp -d)
|
||
local CACHED_VERSION
|
||
|
||
# trap for TMP Cleanup
|
||
trap "rm -rf '$TMP_DIR'" EXIT
|
||
|
||
CACHED_VERSION=$(get_cached_version "uv")
|
||
|
||
# Architecture Detection
|
||
local ARCH=$(uname -m)
|
||
local OS_TYPE=""
|
||
local UV_TAR=""
|
||
|
||
if grep -qi "alpine" /etc/os-release; then
|
||
OS_TYPE="musl"
|
||
else
|
||
OS_TYPE="gnu"
|
||
fi
|
||
|
||
case "$ARCH" in
|
||
x86_64)
|
||
UV_TAR="uv-x86_64-unknown-linux-${OS_TYPE}.tar.gz"
|
||
;;
|
||
aarch64)
|
||
UV_TAR="uv-aarch64-unknown-linux-${OS_TYPE}.tar.gz"
|
||
;;
|
||
i686)
|
||
UV_TAR="uv-i686-unknown-linux-${OS_TYPE}.tar.gz"
|
||
;;
|
||
*)
|
||
msg_error "Unsupported architecture: $ARCH (supported: x86_64, aarch64, i686)"
|
||
return 236
|
||
;;
|
||
esac
|
||
|
||
ensure_dependencies jq
|
||
|
||
# Fetch latest version
|
||
local LATEST_VERSION
|
||
LATEST_VERSION=$(get_latest_github_release "astral-sh/uv") || {
|
||
msg_error "Could not fetch latest uv version from GitHub API"
|
||
return 7
|
||
}
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
if [[ -x "$UV_BIN" ]]; then
|
||
INSTALLED_VERSION=$("$UV_BIN" --version 2>/dev/null | awk '{print $2}')
|
||
fi
|
||
|
||
# Scenario 1: Already at latest version
|
||
if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" == "$LATEST_VERSION" ]]; then
|
||
cache_installed_version "uv" "$LATEST_VERSION"
|
||
|
||
# Check if uvx is needed and missing
|
||
if [[ "${USE_UVX:-NO}" == "YES" ]] && [[ ! -x "$UVX_BIN" ]]; then
|
||
msg_info "Installing uvx wrapper"
|
||
_install_uvx_wrapper || return 252
|
||
msg_ok "uvx wrapper installed"
|
||
fi
|
||
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: New install or upgrade
|
||
if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
|
||
msg_info "Upgrade uv from $INSTALLED_VERSION to $LATEST_VERSION"
|
||
else
|
||
msg_info "Setup uv $LATEST_VERSION"
|
||
fi
|
||
|
||
local UV_URL="https://github.com/astral-sh/uv/releases/download/${LATEST_VERSION}/${UV_TAR}"
|
||
|
||
if ! curl_with_retry "$UV_URL" "$TMP_DIR/uv.tar.gz"; then
|
||
msg_error "Failed to download uv from $UV_URL"
|
||
msg_error "Hint: GitHub Releases — check connectivity or set GITHUB_TOKEN to avoid rate-limiting"
|
||
return 7
|
||
fi
|
||
|
||
# Extract
|
||
$STD tar -xzf "$TMP_DIR/uv.tar.gz" -C "$TMP_DIR" || {
|
||
msg_error "Failed to extract uv"
|
||
return 251
|
||
}
|
||
|
||
# Find and install uv binary (tarball extracts to uv-VERSION-ARCH/ directory)
|
||
local UV_BINARY=$(find "$TMP_DIR" -name "uv" -type f -executable | head -n1)
|
||
if [[ ! -f "$UV_BINARY" ]]; then
|
||
msg_error "Could not find uv binary in extracted tarball"
|
||
return 127
|
||
fi
|
||
|
||
$STD /usr/bin/install -m 755 "$UV_BINARY" "$UV_BIN" || {
|
||
msg_error "Failed to install uv binary"
|
||
return 252
|
||
}
|
||
|
||
ensure_usr_local_bin_persist
|
||
export PATH="/usr/local/bin:$PATH"
|
||
|
||
# Optional: Install uvx wrapper
|
||
if [[ "${USE_UVX:-NO}" == "YES" ]]; then
|
||
msg_info "Installing uvx wrapper"
|
||
_install_uvx_wrapper || {
|
||
msg_error "Failed to install uvx wrapper"
|
||
return 252
|
||
}
|
||
msg_ok "uvx wrapper installed"
|
||
fi
|
||
|
||
# Optional: Generate shell completions
|
||
$STD uv generate-shell-completion bash >/etc/bash_completion.d/uv 2>/dev/null || true
|
||
if [[ -d /usr/share/zsh/site-functions ]]; then
|
||
$STD uv generate-shell-completion zsh >/usr/share/zsh/site-functions/_uv 2>/dev/null || true
|
||
fi
|
||
|
||
# Optional: Install specific Python version if requested
|
||
if [[ -n "${PYTHON_VERSION:-}" ]]; then
|
||
msg_info "Installing Python $PYTHON_VERSION via uv"
|
||
$STD uv python install "$PYTHON_VERSION" || {
|
||
msg_error "Failed to install Python $PYTHON_VERSION"
|
||
return 150
|
||
}
|
||
msg_ok "Python $PYTHON_VERSION installed"
|
||
fi
|
||
|
||
cache_installed_version "uv" "$LATEST_VERSION"
|
||
msg_ok "Setup uv $LATEST_VERSION"
|
||
}
|
||
|
||
# Helper function to install uvx wrapper
|
||
_install_uvx_wrapper() {
|
||
local UVX_BIN="/usr/local/bin/uvx"
|
||
|
||
cat >"$UVX_BIN" <<'EOF'
|
||
#!/bin/bash
|
||
# uvx - Run Python applications from PyPI as command-line tools
|
||
# Wrapper for: uv tool run
|
||
exec /usr/local/bin/uv tool run "$@"
|
||
EOF
|
||
|
||
chmod +x "$UVX_BIN"
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Installs or updates yq (mikefarah/yq - Go version).
|
||
#
|
||
# Description:
|
||
# - Checks if yq is installed and from correct source
|
||
# - Compares with latest release on GitHub
|
||
# - Updates if outdated or wrong implementation
|
||
# ------------------------------------------------------------------------------
|
||
|
||
setup_yq() {
|
||
local TMP_DIR=$(mktemp -d)
|
||
local BINARY_PATH="/usr/local/bin/yq"
|
||
local GITHUB_REPO="mikefarah/yq"
|
||
|
||
ensure_dependencies jq
|
||
ensure_usr_local_bin_persist
|
||
|
||
# Remove non-mikefarah implementations
|
||
if command -v yq &>/dev/null; then
|
||
if ! yq --version 2>&1 | grep -q 'mikefarah'; then
|
||
rm -f "$(command -v yq)"
|
||
fi
|
||
fi
|
||
|
||
local LATEST_VERSION
|
||
LATEST_VERSION=$(get_latest_github_release "${GITHUB_REPO}") || {
|
||
msg_error "Could not fetch latest yq version from GitHub API"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
}
|
||
|
||
# Get currently installed version
|
||
local INSTALLED_VERSION=""
|
||
if command -v yq &>/dev/null && yq --version 2>&1 | grep -q 'mikefarah'; then
|
||
INSTALLED_VERSION=$(yq --version 2>/dev/null | awk '{print $NF}' | sed 's/^v//')
|
||
fi
|
||
|
||
# Scenario 1: Already at latest version
|
||
if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" == "$LATEST_VERSION" ]]; then
|
||
cache_installed_version "yq" "$LATEST_VERSION"
|
||
rm -rf "$TMP_DIR"
|
||
return 0
|
||
fi
|
||
|
||
# Scenario 2: New install or upgrade
|
||
if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
|
||
msg_info "Upgrade yq from $INSTALLED_VERSION to $LATEST_VERSION"
|
||
else
|
||
msg_info "Setup yq $LATEST_VERSION"
|
||
fi
|
||
|
||
local yq_arch detected_arch
|
||
if command -v dpkg &>/dev/null; then
|
||
detected_arch="$(dpkg --print-architecture 2>/dev/null)"
|
||
else
|
||
detected_arch="$(uname -m 2>/dev/null)"
|
||
fi
|
||
|
||
case "$detected_arch" in
|
||
arm64 | aarch64) yq_arch="arm64" ;;
|
||
amd64 | x86_64) yq_arch="amd64" ;;
|
||
*) yq_arch="amd64" ;;
|
||
esac
|
||
if ! curl_with_retry "https://github.com/${GITHUB_REPO}/releases/download/v${LATEST_VERSION}/yq_linux_${yq_arch}" "$TMP_DIR/yq"; then
|
||
msg_error "Failed to download yq"
|
||
msg_error "Hint: Check connectivity to github.com/${GITHUB_REPO} — set GITHUB_TOKEN to avoid rate-limiting"
|
||
rm -rf "$TMP_DIR"
|
||
return 250
|
||
fi
|
||
|
||
chmod +x "$TMP_DIR/yq"
|
||
mv "$TMP_DIR/yq" "$BINARY_PATH" || {
|
||
msg_error "Failed to install yq"
|
||
rm -rf "$TMP_DIR"
|
||
return 252
|
||
}
|
||
|
||
rm -rf "$TMP_DIR"
|
||
hash -r
|
||
|
||
local FINAL_VERSION
|
||
FINAL_VERSION=$("$BINARY_PATH" --version 2>/dev/null | awk '{print $NF}' | sed 's/^v//')
|
||
cache_installed_version "yq" "$FINAL_VERSION"
|
||
msg_ok "Setup yq $FINAL_VERSION"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Fetch and deploy from URL
|
||
# Downloads an archive (zip, tar.gz, or .deb) from a URL and extracts/installs it
|
||
#
|
||
# Usage: fetch_and_deploy_from_url "url" "directory"
|
||
# url - URL to the archive (zip, tar.gz, or .deb)
|
||
# directory - Destination path where the archive will be extracted
|
||
# (not used for .deb packages)
|
||
#
|
||
# Examples:
|
||
# fetch_and_deploy_from_url "https://example.com/app.tar.gz" "/opt/myapp"
|
||
# fetch_and_deploy_from_url "https://example.com/app.zip" "/opt/myapp"
|
||
# fetch_and_deploy_from_url "https://example.com/package.deb" ""
|
||
# ------------------------------------------------------------------------------
|
||
fetch_and_deploy_from_url() {
|
||
local url="$1"
|
||
local directory="${2:-}"
|
||
|
||
if [[ -z "$url" ]]; then
|
||
msg_error "URL parameter is required"
|
||
return 65
|
||
fi
|
||
|
||
local filename="${url##*/}"
|
||
|
||
msg_info "Downloading from $url"
|
||
|
||
local tmpdir
|
||
tmpdir=$(mktemp -d) || {
|
||
msg_error "Failed to create temporary directory"
|
||
return 252
|
||
}
|
||
|
||
curl -fsSL -o "$tmpdir/$filename" "$url" || {
|
||
msg_error "Download failed: $url"
|
||
rm -rf "$tmpdir"
|
||
return 250
|
||
}
|
||
|
||
# Auto-detect archive type using file description
|
||
local file_desc
|
||
file_desc=$(file -b "$tmpdir/$filename")
|
||
|
||
local archive_type="unknown"
|
||
|
||
if [[ "$file_desc" =~ gzip.*compressed|gzip\ compressed\ data ]]; then
|
||
archive_type="tar"
|
||
elif [[ "$file_desc" =~ Zip.*archive|ZIP\ archive ]]; then
|
||
archive_type="zip"
|
||
elif [[ "$file_desc" =~ Debian.*package|Debian\ binary\ package ]]; then
|
||
archive_type="deb"
|
||
elif [[ "$file_desc" =~ POSIX.*tar.*archive|tar\ archive ]]; then
|
||
archive_type="tar"
|
||
else
|
||
msg_error "Unsupported or unknown archive type: $file_desc"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
fi
|
||
|
||
msg_info "Detected archive type: $archive_type (file type: $file_desc)"
|
||
|
||
if [[ "$archive_type" == "deb" ]]; then
|
||
msg_info "Installing .deb package"
|
||
|
||
chmod 644 "$tmpdir/$filename"
|
||
$STD apt install -y "$tmpdir/$filename" || {
|
||
$STD dpkg -i "$tmpdir/$filename" || {
|
||
_diagnose_deb_failure "$tmpdir/$filename"
|
||
rm -rf "$tmpdir"
|
||
return 100
|
||
}
|
||
}
|
||
|
||
rm -rf "$tmpdir"
|
||
msg_ok "Successfully installed .deb package"
|
||
return 0
|
||
fi
|
||
|
||
if [[ -z "$directory" ]]; then
|
||
msg_error "Directory parameter is required for archive extraction"
|
||
rm -rf "$tmpdir"
|
||
return 65
|
||
fi
|
||
|
||
msg_info "Extracting archive to $directory"
|
||
|
||
mkdir -p "$directory"
|
||
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${directory:?}/"*
|
||
fi
|
||
|
||
local unpack_tmp
|
||
unpack_tmp=$(mktemp -d)
|
||
|
||
if [[ "$archive_type" == "zip" ]]; then
|
||
ensure_dependencies unzip
|
||
unzip -q "$tmpdir/$filename" -d "$unpack_tmp" || {
|
||
msg_error "Failed to extract ZIP archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
}
|
||
elif [[ "$archive_type" == "tar" ]]; then
|
||
tar --no-same-owner -xf "$tmpdir/$filename" -C "$unpack_tmp" || {
|
||
msg_error "Failed to extract TAR archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 251
|
||
}
|
||
fi
|
||
|
||
local top_entries
|
||
top_entries=$(find "$unpack_tmp" -mindepth 1 -maxdepth 1)
|
||
|
||
if [[ "$(echo "$top_entries" | wc -l)" -eq 1 && -d "$top_entries" ]]; then
|
||
local inner_dir="$top_entries"
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$inner_dir/*" >/dev/null; then
|
||
cp -r "$inner_dir"/* "$directory/" || {
|
||
msg_error "Failed to copy contents from $inner_dir to $directory"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
}
|
||
else
|
||
msg_error "Inner directory is empty: $inner_dir"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
else
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$unpack_tmp/*" >/dev/null; then
|
||
cp -r "$unpack_tmp"/* "$directory/" || {
|
||
msg_error "Failed to copy contents to $directory"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
}
|
||
else
|
||
msg_error "Unpacked archive is empty"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 252
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
fi
|
||
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
msg_ok "Successfully deployed archive to $directory"
|
||
return 0
|
||
}
|
||
|
||
setup_nonfree() {
|
||
local sources_file="/etc/apt/sources.list.d/debian-nonfree.sources"
|
||
|
||
if [ ! -f "$sources_file" ]; then
|
||
cat <<EOF >$sources_file
|
||
Types: deb
|
||
URIs: http://deb.debian.org/debian
|
||
Suites: trixie trixie-updates
|
||
Components: main contrib non-free non-free-firmware
|
||
|
||
Types: deb
|
||
URIs: http://security.debian.org/debian-security
|
||
Suites: trixie-security
|
||
Components: main contrib non-free non-free-firmware
|
||
EOF
|
||
fi
|
||
$STD apt update
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Get latest GitLab release version.
|
||
# Usage: get_latest_gitlab_release "owner/repo" [strip_v]
|
||
# ------------------------------------------------------------------------------
|
||
get_latest_gitlab_release() {
|
||
local repo="$1"
|
||
local strip_v="${2:-true}"
|
||
|
||
local repo_encoded
|
||
repo_encoded=$(printf '%s' "$repo" | sed 's|/|%2F|g')
|
||
|
||
local header=()
|
||
[[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
|
||
|
||
local temp_file
|
||
temp_file=$(mktemp)
|
||
|
||
local http_code
|
||
http_code=$(curl --connect-timeout 10 --max-time 30 -sSL \
|
||
-w "%{http_code}" -o "$temp_file" \
|
||
"${header[@]}" \
|
||
"https://gitlab.com/api/v4/projects/$repo_encoded/releases?per_page=1&order_by=released_at&sort=desc" 2>/dev/null) || true
|
||
|
||
if [[ "$http_code" != "200" ]]; then
|
||
rm -f "$temp_file"
|
||
msg_warn "GitLab API call failed for ${repo} (HTTP ${http_code})"
|
||
return 22
|
||
fi
|
||
|
||
local version
|
||
version=$(jq -r '.[0].tag_name // empty' "$temp_file")
|
||
rm -f "$temp_file"
|
||
|
||
if [[ -z "$version" ]]; then
|
||
msg_error "Could not determine latest version for ${repo}"
|
||
return 250
|
||
fi
|
||
|
||
if [[ "$strip_v" == "true" ]]; then
|
||
[[ "$version" =~ ^v[0-9] ]] && version="${version:1}"
|
||
fi
|
||
|
||
echo "$version"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Checks for new GitLab release (latest tag).
|
||
#
|
||
# Description:
|
||
# - Queries the GitLab API for the latest release tag
|
||
# - Compares it to a local cached version (~/.<app>)
|
||
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
|
||
#
|
||
# Usage:
|
||
# if check_for_gl_release "myapp" "owner/repo" [optional] "v1.2.3"; then
|
||
# # trigger update...
|
||
# fi
|
||
# exit 0
|
||
# } (end of update_script not from the function)
|
||
#
|
||
# Notes:
|
||
# - Requires `jq` (auto-installed if missing)
|
||
# - Supports GITLAB_TOKEN env var for private/rate-limited repos
|
||
# - Does not modify anything, only checks version state
|
||
# ------------------------------------------------------------------------------
|
||
check_for_gl_release() {
|
||
local app="$1"
|
||
local source="$2"
|
||
local pinned_version_in="${3:-}" # optional
|
||
local pin_reason="${4:-}" # optional reason shown to user
|
||
local app_lc="${app,,}"
|
||
local current_file="$HOME/.${app_lc}"
|
||
|
||
msg_info "Checking for update: ${app}"
|
||
|
||
# DNS check
|
||
if ! getent hosts gitlab.com >/dev/null 2>&1; then
|
||
msg_error "Network error: cannot resolve gitlab.com"
|
||
return 6
|
||
fi
|
||
|
||
ensure_dependencies jq
|
||
|
||
local repo_encoded
|
||
repo_encoded=$(printf '%s' "$source" | sed 's|/|%2F|g')
|
||
|
||
local header=()
|
||
[[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
|
||
|
||
local releases_json="" http_code=""
|
||
|
||
# For pinned versions, try to fetch the specific release tag first
|
||
if [[ -n "$pinned_version_in" ]]; then
|
||
local pinned_encoded="${pinned_version_in//\//%2F}"
|
||
http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o /tmp/gl_check.json \
|
||
"${header[@]}" \
|
||
"https://gitlab.com/api/v4/projects/$repo_encoded/releases/$pinned_encoded" 2>/dev/null) || true
|
||
if [[ "$http_code" == "200" ]] && [[ -s /tmp/gl_check.json ]]; then
|
||
releases_json="[$(</tmp/gl_check.json)]"
|
||
fi
|
||
rm -f /tmp/gl_check.json
|
||
fi
|
||
|
||
# Fetch full releases list if needed
|
||
if [[ -z "$releases_json" ]]; then
|
||
http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o /tmp/gl_check.json \
|
||
"${header[@]}" \
|
||
"https://gitlab.com/api/v4/projects/$repo_encoded/releases?per_page=100&order_by=released_at&sort=desc" 2>/dev/null) || true
|
||
|
||
if [[ "$http_code" == "200" ]] && [[ -s /tmp/gl_check.json ]]; then
|
||
releases_json=$(</tmp/gl_check.json)
|
||
elif [[ "$http_code" == "401" ]]; then
|
||
msg_error "GitLab API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITLAB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITLAB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication. Try: export GITLAB_TOKEN=\"glpat-your_token\""
|
||
fi
|
||
rm -f /tmp/gl_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "404" ]]; then
|
||
msg_error "GitLab project not found (HTTP 404). Ensure '${source}' is correct and publicly accessible."
|
||
rm -f /tmp/gl_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "429" ]]; then
|
||
msg_error "GitLab API rate limit exceeded (HTTP 429)."
|
||
msg_error "To increase the limit, export a GitLab token: export GITLAB_TOKEN=\"glpat-your_token_here\""
|
||
rm -f /tmp/gl_check.json
|
||
return 22
|
||
elif [[ "$http_code" == "000" || -z "$http_code" ]]; then
|
||
msg_error "GitLab API connection failed (no response)."
|
||
msg_error "Check your network/DNS: curl -sSL https://gitlab.com/api/v4/version"
|
||
rm -f /tmp/gl_check.json
|
||
return 7
|
||
else
|
||
msg_error "Unable to fetch releases for ${app} (HTTP ${http_code})"
|
||
rm -f /tmp/gl_check.json
|
||
return 22
|
||
fi
|
||
rm -f /tmp/gl_check.json
|
||
fi
|
||
|
||
mapfile -t raw_tags < <(jq -r '.[] | .tag_name' <<<"$releases_json")
|
||
if ((${#raw_tags[@]} == 0)); then
|
||
msg_error "No releases found for ${app} on GitLab"
|
||
return 250
|
||
fi
|
||
|
||
local clean_tags=()
|
||
for t in "${raw_tags[@]}"; do
|
||
# Only strip leading 'v' when followed by a digit (e.g. v1.2.3)
|
||
if [[ "$t" =~ ^v[0-9] ]]; then
|
||
clean_tags+=("${t:1}")
|
||
else
|
||
clean_tags+=("$t")
|
||
fi
|
||
done
|
||
|
||
local latest_raw="${raw_tags[0]}"
|
||
local latest_clean="${clean_tags[0]}"
|
||
|
||
# current installed (stored without v)
|
||
local current=""
|
||
if [[ -f "$current_file" ]]; then
|
||
current="$(<"$current_file")"
|
||
else
|
||
# Migration: search for any /opt/*_version.txt
|
||
local legacy_files
|
||
mapfile -t legacy_files < <(find /opt -maxdepth 1 -type f -name "*_version.txt" 2>/dev/null)
|
||
if ((${#legacy_files[@]} == 1)); then
|
||
current="$(<"${legacy_files[0]}")"
|
||
echo "${current#v}" >"$current_file"
|
||
rm -f "${legacy_files[0]}"
|
||
fi
|
||
fi
|
||
if [[ "$current" =~ ^v[0-9] ]]; then
|
||
current="${current:1}"
|
||
fi
|
||
|
||
# Pinned version handling
|
||
if [[ -n "$pinned_version_in" ]]; then
|
||
local pin_clean
|
||
if [[ "$pinned_version_in" =~ ^v[0-9] ]]; then
|
||
pin_clean="${pinned_version_in:1}"
|
||
else
|
||
pin_clean="$pinned_version_in"
|
||
fi
|
||
local match_raw=""
|
||
for i in "${!clean_tags[@]}"; do
|
||
if [[ "${clean_tags[$i]}" == "$pin_clean" ]]; then
|
||
match_raw="${raw_tags[$i]}"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [[ -z "$match_raw" ]]; then
|
||
msg_error "Pinned version ${pinned_version_in} not found upstream"
|
||
return 250
|
||
fi
|
||
|
||
if [[ "$current" != "$pin_clean" ]]; then
|
||
CHECK_UPDATE_RELEASE="$match_raw"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${pin_clean}"
|
||
return 0
|
||
fi
|
||
|
||
if [[ -n "$pin_reason" ]]; then
|
||
msg_ok "No update available: ${app} (${current}) - update held back: ${pin_reason}"
|
||
else
|
||
msg_ok "No update available: ${app} (${current}) - update temporarily held back due to issues with newer releases"
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
# No pinning → use latest
|
||
if [[ -z "$current" || "$current" != "$latest_clean" ]]; then
|
||
CHECK_UPDATE_RELEASE="$latest_raw"
|
||
msg_ok "Update available: ${app} ${current:-not installed} → ${latest_clean}"
|
||
return 0
|
||
fi
|
||
|
||
msg_ok "No update available: ${app} (${latest_clean})"
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Scan older GitLab releases for a matching asset (fallback helper).
|
||
#
|
||
# Description:
|
||
# When the latest release does not contain the expected asset
|
||
# (e.g. .deb for the current arch, or a custom pattern), walks back
|
||
# through up to 15 recent releases and returns the first release JSON
|
||
# that has a matching asset. Used internally by fetch_and_deploy_gl_release.
|
||
#
|
||
# Usage (internal):
|
||
# _gl_scan_older_releases "owner/repo" "owner%2Frepo" "https://gitlab.com" \
|
||
# "binary|prebuild|singlefile" "$asset_pattern" "$skip_tag"
|
||
#
|
||
# Returns:
|
||
# - stdout: JSON of the matching release (single object) on success
|
||
# - 0 on success, 22 on API error, 250 if no match found
|
||
# ------------------------------------------------------------------------------
|
||
_gl_scan_older_releases() {
|
||
local repo="$1"
|
||
local repo_encoded="$2"
|
||
local base_url="${3:-https://gitlab.com}"
|
||
local mode="$4"
|
||
local asset_pattern="$5"
|
||
local skip_tag="$6"
|
||
|
||
local header=()
|
||
[[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
|
||
|
||
local releases_list
|
||
releases_list=$(curl --connect-timeout 10 --max-time 30 -fsSL \
|
||
"${header[@]}" \
|
||
"${base_url}/api/v4/projects/${repo_encoded}/releases?per_page=15&order_by=released_at&sort=desc" 2>/dev/null) || {
|
||
msg_warn "Failed to fetch older releases for ${repo}"
|
||
return 22
|
||
}
|
||
|
||
local count
|
||
count=$(echo "$releases_list" | jq 'length' 2>/dev/null || echo 0)
|
||
[[ "$count" -eq 0 ]] && return 250
|
||
|
||
for ((i = 0; i < count; i++)); do
|
||
local rel_tag
|
||
rel_tag=$(echo "$releases_list" | jq -r ".[$i].tag_name")
|
||
|
||
# Skip the tag we already checked
|
||
[[ "$rel_tag" == "$skip_tag" ]] && continue
|
||
|
||
# Asset URLs for this release (direct_asset_url preferred, fallback to url)
|
||
local asset_urls
|
||
asset_urls=$(echo "$releases_list" | jq -r ".[$i].assets.links // [] | .[] | .direct_asset_url // .url")
|
||
[[ -z "$asset_urls" ]] && continue
|
||
|
||
local has_match=false
|
||
|
||
if [[ "$mode" == "binary" ]]; then
|
||
local arch
|
||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
|
||
# Check with explicit pattern first, then arch heuristic, then any .deb
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
while read -r u; do
|
||
case "${u##*/}" in $asset_pattern)
|
||
has_match=true
|
||
break
|
||
;;
|
||
esac
|
||
done <<<"$asset_urls"
|
||
fi
|
||
if [[ "$has_match" != "true" ]]; then
|
||
echo "$asset_urls" | grep -qE "($arch|amd64|x86_64|aarch64|arm64).*\.deb$" && has_match=true
|
||
fi
|
||
if [[ "$has_match" != "true" ]]; then
|
||
echo "$asset_urls" | grep -qE '\.deb$' && has_match=true
|
||
fi
|
||
|
||
elif [[ "$mode" == "prebuild" || "$mode" == "singlefile" ]]; then
|
||
while read -r u; do
|
||
case "${u##*/}" in $asset_pattern)
|
||
has_match=true
|
||
break
|
||
;;
|
||
esac
|
||
done <<<"$asset_urls"
|
||
fi
|
||
|
||
if [[ "$has_match" == "true" ]]; then
|
||
local use_fallback="y"
|
||
if [[ -t 0 ]]; then
|
||
msg_warn "Release ${skip_tag} has no matching asset. Previous release ${rel_tag} has a compatible asset."
|
||
read -rp "Use version ${rel_tag} instead? [Y/n] (auto-yes in 60s): " -t 60 use_fallback || use_fallback="y"
|
||
use_fallback="${use_fallback:-y}"
|
||
fi
|
||
|
||
if [[ "${use_fallback,,}" == "y" || "${use_fallback,,}" == "yes" ]]; then
|
||
echo "$releases_list" | jq ".[$i]"
|
||
return 0
|
||
else
|
||
return 250
|
||
fi
|
||
fi
|
||
done
|
||
|
||
return 250
|
||
}
|
||
|
||
fetch_and_deploy_gl_release() {
|
||
local app="$1"
|
||
local repo="$2"
|
||
local mode="${3:-tarball}"
|
||
local version="${var_appversion:-${4:-latest}}"
|
||
local target="${5:-/opt/$app}"
|
||
local asset_pattern="${6:-}"
|
||
|
||
if [[ -z "$app" ]]; then
|
||
app="${repo##*/}"
|
||
if [[ -z "$app" ]]; then
|
||
msg_error "fetch_and_deploy_gl_release requires app name or valid repo"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
local app_lc=$(echo "${app,,}" | tr -d ' ')
|
||
local version_file="$HOME/.${app_lc}"
|
||
|
||
local api_timeout="--connect-timeout 10 --max-time 60"
|
||
local download_timeout="--connect-timeout 15 --max-time 900"
|
||
|
||
local current_version=""
|
||
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
|
||
|
||
ensure_dependencies jq
|
||
|
||
local repo_encoded
|
||
repo_encoded=$(printf '%s' "$repo" | sed 's|/|%2F|g')
|
||
|
||
local api_base="https://gitlab.com/api/v4/projects/$repo_encoded/releases"
|
||
local api_url
|
||
if [[ "$version" != "latest" ]]; then
|
||
api_url="$api_base/$version"
|
||
else
|
||
api_url="$api_base?per_page=1&order_by=released_at&sort=desc"
|
||
fi
|
||
|
||
local header=()
|
||
[[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
|
||
|
||
local max_retries=3 retry_delay=2 attempt=1 success=false http_code
|
||
|
||
while ((attempt <= max_retries)); do
|
||
http_code=$(curl $api_timeout -sSL -w "%{http_code}" -o /tmp/gl_rel.json "${header[@]}" "$api_url" 2>/dev/null) || true
|
||
if [[ "$http_code" == "200" ]]; then
|
||
success=true
|
||
break
|
||
elif [[ "$http_code" == "429" ]]; then
|
||
if ((attempt < max_retries)); then
|
||
msg_warn "GitLab API rate limit hit, retrying in ${retry_delay}s... (attempt $attempt/$max_retries)"
|
||
sleep "$retry_delay"
|
||
retry_delay=$((retry_delay * 2))
|
||
fi
|
||
else
|
||
sleep "$retry_delay"
|
||
fi
|
||
((attempt++))
|
||
done
|
||
|
||
if ! $success; then
|
||
if [[ "$http_code" == "401" ]]; then
|
||
msg_error "GitLab API authentication failed (HTTP 401)."
|
||
if [[ -n "${GITLAB_TOKEN:-}" ]]; then
|
||
msg_error "Your GITLAB_TOKEN appears to be invalid or expired."
|
||
else
|
||
msg_error "The repository may require authentication. Try: export GITLAB_TOKEN=\"glpat-your_token\""
|
||
fi
|
||
elif [[ "$http_code" == "404" ]]; then
|
||
msg_error "GitLab project or release not found (HTTP 404)."
|
||
msg_error "Ensure '$repo' is correct and the project is accessible."
|
||
elif [[ "$http_code" == "429" ]]; then
|
||
msg_error "GitLab API rate limit exceeded (HTTP 429)."
|
||
msg_error "To increase the limit, export a GitLab token before running the script:"
|
||
msg_error " export GITLAB_TOKEN=\"glpat-your_token_here\""
|
||
elif [[ "$http_code" == "000" || -z "$http_code" ]]; then
|
||
msg_error "GitLab API connection failed (no response)."
|
||
msg_error "Check your network/DNS: curl -sSL https://gitlab.com/api/v4/version"
|
||
else
|
||
msg_error "Failed to fetch release metadata (HTTP $http_code)"
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
local json tag_name
|
||
json=$(</tmp/gl_rel.json)
|
||
|
||
if [[ "$version" == "latest" ]]; then
|
||
json=$(echo "$json" | jq '.[0] // empty')
|
||
if [[ -z "$json" || "$json" == "null" ]]; then
|
||
msg_error "No releases found for $repo on GitLab"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // empty')
|
||
if [[ -z "$tag_name" ]]; then
|
||
msg_error "Could not determine tag name from release metadata"
|
||
return 1
|
||
fi
|
||
[[ "$tag_name" =~ ^v[0-9] ]] && version="${tag_name:1}" || version="$tag_name"
|
||
local version_safe="${version//\//-}"
|
||
|
||
if [[ "$current_version" == "$version" ]]; then
|
||
$STD msg_ok "$app is already up-to-date (v$version)"
|
||
return 0
|
||
fi
|
||
|
||
local tmpdir
|
||
tmpdir=$(mktemp -d) || return 1
|
||
local filename=""
|
||
|
||
msg_info "Fetching GitLab release: $app ($version)"
|
||
|
||
_gl_asset_urls() {
|
||
local release_json="$1"
|
||
echo "$release_json" | jq -r '
|
||
(.assets.links // [])[] | .direct_asset_url // .url
|
||
'
|
||
}
|
||
|
||
### Tarball Mode ###
|
||
if [[ "$mode" == "tarball" || "$mode" == "source" ]]; then
|
||
local direct_tarball_url="https://gitlab.com/$repo/-/archive/$tag_name/${app_lc}-${version_safe}.tar.gz"
|
||
filename="${app_lc}-${version_safe}.tar.gz"
|
||
|
||
curl $download_timeout -fsSL "${header[@]}" -o "$tmpdir/$filename" "$direct_tarball_url" || {
|
||
msg_error "Download failed: $direct_tarball_url"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
tar --no-same-owner -xzf "$tmpdir/$filename" -C "$tmpdir" || {
|
||
msg_error "Failed to extract tarball"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
local unpack_dir
|
||
unpack_dir=$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d | head -n1)
|
||
|
||
shopt -s dotglob nullglob
|
||
cp -r "$unpack_dir"/* "$target/"
|
||
shopt -u dotglob nullglob
|
||
|
||
### Binary Mode ###
|
||
elif [[ "$mode" == "binary" ]]; then
|
||
local arch
|
||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||
[[ "$arch" == "x86_64" ]] && arch="amd64"
|
||
[[ "$arch" == "aarch64" ]] && arch="arm64"
|
||
|
||
local assets url_match=""
|
||
assets=$(_gl_asset_urls "$json")
|
||
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
for u in $assets; do
|
||
case "${u##*/}" in
|
||
$asset_pattern)
|
||
url_match="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
if [[ "$u" =~ ($arch|amd64|x86_64|aarch64|arm64).*\.deb$ ]]; then
|
||
url_match="$u"
|
||
break
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] && url_match="$u" && break
|
||
done
|
||
fi
|
||
|
||
if [[ -z "$url_match" ]]; then
|
||
local fallback_json
|
||
if fallback_json=$(_gl_scan_older_releases "$repo" "$repo_encoded" "https://gitlab.com" "binary" "$asset_pattern" "$tag_name"); then
|
||
json="$fallback_json"
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // empty')
|
||
[[ "$tag_name" =~ ^v[0-9] ]] && version="${tag_name:1}" || version="$tag_name"
|
||
msg_info "Fetching GitLab release: $app ($version)"
|
||
assets=$(_gl_asset_urls "$json")
|
||
if [[ -n "$asset_pattern" ]]; then
|
||
for u in $assets; do
|
||
case "${u##*/}" in $asset_pattern)
|
||
url_match="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ ($arch|amd64|x86_64|aarch64|arm64).*\.deb$ ]] && url_match="$u" && break
|
||
done
|
||
fi
|
||
if [[ -z "$url_match" ]]; then
|
||
for u in $assets; do
|
||
[[ "$u" =~ \.deb$ ]] && url_match="$u" && break
|
||
done
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "$url_match" ]]; then
|
||
msg_error "No suitable .deb asset found for $app"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
fi
|
||
|
||
filename="${url_match##*/}"
|
||
curl $download_timeout -fsSL "${header[@]}" -o "$tmpdir/$filename" "$url_match" || {
|
||
msg_error "Download failed: $url_match"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
chmod 644 "$tmpdir/$filename"
|
||
local dpkg_opts=""
|
||
[[ "${DPKG_FORCE_CONFOLD:-}" == "1" ]] && dpkg_opts="-o Dpkg::Options::=--force-confold"
|
||
[[ "${DPKG_FORCE_CONFNEW:-}" == "1" ]] && dpkg_opts="-o Dpkg::Options::=--force-confnew"
|
||
DEBIAN_FRONTEND=noninteractive SYSTEMD_OFFLINE=1 $STD apt install -y $dpkg_opts "$tmpdir/$filename" || {
|
||
SYSTEMD_OFFLINE=1 $STD dpkg -i "$tmpdir/$filename" || {
|
||
_diagnose_deb_failure "$tmpdir/$filename"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
}
|
||
|
||
### Prebuild Mode ###
|
||
elif [[ "$mode" == "prebuild" ]]; then
|
||
local pattern="${6%\"}"
|
||
pattern="${pattern#\"}"
|
||
[[ -z "$pattern" ]] && {
|
||
msg_error "Mode 'prebuild' requires 6th parameter (asset filename pattern)"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
local asset_url=""
|
||
for u in $(_gl_asset_urls "$json"); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in
|
||
$pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [[ -z "$asset_url" ]]; then
|
||
local fallback_json
|
||
if fallback_json=$(_gl_scan_older_releases "$repo" "$repo_encoded" "https://gitlab.com" "prebuild" "$pattern" "$tag_name"); then
|
||
json="$fallback_json"
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // empty')
|
||
[[ "$tag_name" =~ ^v[0-9] ]] && version="${tag_name:1}" || version="$tag_name"
|
||
msg_info "Fetching GitLab release: $app ($version)"
|
||
for u in $(_gl_asset_urls "$json"); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in $pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
fi
|
||
|
||
[[ -z "$asset_url" ]] && {
|
||
msg_error "No asset matching '$pattern' found"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
filename="${asset_url##*/}"
|
||
curl $download_timeout -fsSL "${header[@]}" -o "$tmpdir/$filename" "$asset_url" || {
|
||
msg_error "Download failed: $asset_url"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
local unpack_tmp
|
||
unpack_tmp=$(mktemp -d)
|
||
mkdir -p "$target"
|
||
if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then
|
||
rm -rf "${target:?}/"*
|
||
fi
|
||
|
||
if [[ "$filename" == *.zip ]]; then
|
||
ensure_dependencies unzip
|
||
unzip -q "$tmpdir/$filename" -d "$unpack_tmp" || {
|
||
msg_error "Failed to extract ZIP archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
}
|
||
elif [[ "$filename" == *.tar.* || "$filename" == *.tgz || "$filename" == *.txz ]]; then
|
||
tar --no-same-owner -xf "$tmpdir/$filename" -C "$unpack_tmp" || {
|
||
msg_error "Failed to extract TAR archive"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
}
|
||
else
|
||
msg_error "Unsupported archive format: $filename"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
fi
|
||
|
||
local top_entries inner_dir
|
||
top_entries=$(find "$unpack_tmp" -mindepth 1 -maxdepth 1)
|
||
if [[ "$(echo "$top_entries" | wc -l)" -eq 1 && -d "$top_entries" ]]; then
|
||
inner_dir="$top_entries"
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$inner_dir/*" >/dev/null; then
|
||
cp -r "$inner_dir"/* "$target/" || {
|
||
msg_error "Failed to copy contents from $inner_dir to $target"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
}
|
||
else
|
||
msg_error "Inner directory is empty: $inner_dir"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
else
|
||
shopt -s dotglob nullglob
|
||
if compgen -G "$unpack_tmp/*" >/dev/null; then
|
||
cp -r "$unpack_tmp"/* "$target/" || {
|
||
msg_error "Failed to copy contents to $target"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
}
|
||
else
|
||
msg_error "Unpacked archive is empty"
|
||
rm -rf "$tmpdir" "$unpack_tmp"
|
||
return 1
|
||
fi
|
||
shopt -u dotglob nullglob
|
||
fi
|
||
|
||
### Singlefile Mode ###
|
||
elif [[ "$mode" == "singlefile" ]]; then
|
||
local pattern="${6%\"}"
|
||
pattern="${pattern#\"}"
|
||
[[ -z "$pattern" ]] && {
|
||
msg_error "Mode 'singlefile' requires 6th parameter (asset filename pattern)"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
local asset_url=""
|
||
for u in $(_gl_asset_urls "$json"); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in
|
||
$pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [[ -z "$asset_url" ]]; then
|
||
local fallback_json
|
||
if fallback_json=$(_gl_scan_older_releases "$repo" "$repo_encoded" "https://gitlab.com" "singlefile" "$pattern" "$tag_name"); then
|
||
json="$fallback_json"
|
||
tag_name=$(echo "$json" | jq -r '.tag_name // empty')
|
||
[[ "$tag_name" =~ ^v[0-9] ]] && version="${tag_name:1}" || version="$tag_name"
|
||
msg_info "Fetching GitLab release: $app ($version)"
|
||
for u in $(_gl_asset_urls "$json"); do
|
||
filename_candidate="${u##*/}"
|
||
case "$filename_candidate" in $pattern)
|
||
asset_url="$u"
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
fi
|
||
fi
|
||
|
||
[[ -z "$asset_url" ]] && {
|
||
msg_error "No asset matching '$pattern' found"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
filename="${asset_url##*/}"
|
||
mkdir -p "$target"
|
||
|
||
local use_filename="${USE_ORIGINAL_FILENAME:-false}"
|
||
local target_file="$app"
|
||
[[ "$use_filename" == "true" ]] && target_file="$filename"
|
||
|
||
curl $download_timeout -fsSL "${header[@]}" -o "$target/$target_file" "$asset_url" || {
|
||
msg_error "Download failed: $asset_url"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
}
|
||
|
||
if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then
|
||
chmod +x "$target/$target_file"
|
||
fi
|
||
|
||
else
|
||
msg_error "Unknown mode: $mode"
|
||
rm -rf "$tmpdir"
|
||
return 1
|
||
fi
|
||
|
||
echo "$version" >"$version_file"
|
||
msg_ok "Deployed: $app ($version)"
|
||
rm -rf "$tmpdir"
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Download NLTK data packages directly from GitHub, bypassing Python.
|
||
# Avoids CPU-instruction failures (SIGILL) on older hardware lacking AVX.
|
||
#
|
||
# Usage:
|
||
# setup_nltk "averaged_perceptron_tagger_eng" "/nltk_data"
|
||
# setup_nltk "snowball_data stopwords punkt_tab" "/usr/share/nltk_data"
|
||
#
|
||
# Parameters:
|
||
# $1 - Space-separated list of NLTK package IDs
|
||
# $2 - Target directory (default: /usr/share/nltk_data)
|
||
#
|
||
# Returns: 0 on success, non-zero if any package failed
|
||
# ------------------------------------------------------------------------------
|
||
setup_nltk() {
|
||
local packages="${1:?setup_nltk requires at least one package name}"
|
||
local target_dir="${2:-/usr/share/nltk_data}"
|
||
local NLTK_INDEX_URL="https://raw.githubusercontent.com/nltk/nltk_data/gh-pages/index.xml"
|
||
local index_xml rc=0
|
||
|
||
ensure_dependencies unzip
|
||
|
||
index_xml=$(curl_with_retry "$NLTK_INDEX_URL" "-") || {
|
||
msg_error "Failed to fetch NLTK package index"
|
||
return 1
|
||
}
|
||
|
||
local pkg
|
||
for pkg in $packages; do
|
||
msg_info "Downloading NLTK: $pkg"
|
||
local pkg_line subdir pkg_url do_unzip tmp_zip
|
||
|
||
pkg_line=$(echo "$index_xml" | grep "id=\"${pkg}\"" | head -1)
|
||
if [[ -z "$pkg_line" ]]; then
|
||
msg_error "NLTK package not found in index: $pkg"
|
||
rc=1
|
||
continue
|
||
fi
|
||
|
||
subdir=$(echo "$pkg_line" | grep -oP 'subdir="\K[^"]+')
|
||
pkg_url=$(echo "$pkg_line" | grep -oP 'url="\K[^"]+')
|
||
do_unzip=$(echo "$pkg_line" | grep -oP 'unzip="\K[^"]+')
|
||
|
||
if [[ -z "$subdir" || -z "$pkg_url" ]]; then
|
||
msg_error "Could not parse NLTK index entry for: $pkg"
|
||
rc=1
|
||
continue
|
||
fi
|
||
|
||
mkdir -p "${target_dir}/${subdir}"
|
||
tmp_zip=$(mktemp --suffix=.zip)
|
||
|
||
if CURL_TIMEOUT=120 curl_with_retry "$pkg_url" "$tmp_zip"; then
|
||
if [[ "$do_unzip" == "1" ]]; then
|
||
$STD unzip -q -o "$tmp_zip" -d "${target_dir}/${subdir}/"
|
||
rm -f "$tmp_zip"
|
||
else
|
||
mv "$tmp_zip" "${target_dir}/${subdir}/${pkg}.zip"
|
||
fi
|
||
msg_ok "Downloaded NLTK: $pkg"
|
||
else
|
||
msg_error "Failed to download NLTK package: $pkg"
|
||
rm -f "$tmp_zip"
|
||
rc=1
|
||
fi
|
||
done
|
||
|
||
return $rc
|
||
}
|