commit 1660ce49398a4771577a162e490afefa437a9894 Author: upstream Date: Wed Aug 26 16:26:08 2026 +0200 Upstream community-scripts/ProxmoxVE state of the touched files before b34687cc47 diff --git a/misc/tools.func b/misc/tools.func new file mode 100644 index 0000000..bfe6cba --- /dev/null +++ b/misc/tools.func @@ -0,0 +1,10231 @@ +#!/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 +# version_matches_spec() - Compare installed semver against spec (8.0 matches 8.0.40) +# 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 +# +# ============================================================================== + +# Guard against double-sourcing (core.func uses the same pattern) +[[ -n "${_TOOLS_FUNC_LOADED:-}" ]] && return 0 +_TOOLS_FUNC_LOADED=1 + +# ------------------------------------------------------------------------------ +# 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" + + # Build curl command as array to avoid command injection via extra_opts + local -a curl_args=(curl -fsSL --connect-timeout "$connect_timeout" --max-time "$timeout") + [[ -n "$extra_opts" ]] && read -ra _extra <<<"$extra_opts" && curl_args+=("${_extra[@]}") + + if [[ "$output" == "-" ]]; then + if "${curl_args[@]}" "$url"; then + success=true + break + fi + else + if "${curl_args[@]}" -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" + + # Build curl command as array to avoid command injection via extra_opts + local -a curl_args=(curl -fsSL --connect-timeout "$connect_timeout" --max-time "$timeout" -w '%{http_code}') + [[ -n "$extra_opts" ]] && read -ra _extra <<<"$extra_opts" && curl_args+=("${_extra[@]}") + + if [[ -n "$body_file" ]]; then + http_code=$("${curl_args[@]}" -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_args[@]}" -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 +} + +# ------------------------------------------------------------------------------ +# Edit a key-value pair in a YAML configuration file. +# +# Description: +# - Finds the first occurrence of the given key in the YAML file and replaces +# its value in-place using sed. +# - Handles both quoted and unquoted values on the same line. +# - The key is matched at the start of a line (optionally preceded by spaces), +# followed by a colon and optional whitespace. +# - If the key is not found, the function exits with a non-zero status and +# prints an error message. +# +# Usage: +# edit_yaml_config "/opt/myapp/config.yml" "database_host" "localhost" +# edit_yaml_config "/opt/myapp/config.yml" "port" "5432" +# +# Parameters: +# $1 - Path to the YAML file +# $2 - Key to find (e.g. "database_host") +# $3 - New value to set (e.g. "localhost") +# +# Returns: 0 on success, 1 if the file does not exist or the key is not found +# ------------------------------------------------------------------------------ +edit_yaml_config() { + local file="$1" + local key="$2" + local value="$3" + + if [[ ! -f "$file" ]]; then + msg_error "edit_yaml_config: file not found: $file" + return 1 + fi + + if ! grep -qE "^[[:space:]]*${key}[[:space:]]*:" "$file"; then + msg_error "edit_yaml_config: key '${key}' not found in $file" + return 1 + fi + + # Escape sed metacharacters in value (| and &) to prevent injection + local escaped_value="${value//|/\\|}" + escaped_value="${escaped_value//&/\\&}" + sed -i "s|^\([[:space:]]*${key}[[:space:]]*:\).*|\1 ${escaped_value}|" "$file" +} + +# ------------------------------------------------------------------------------ +# 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 1 +} + +# ------------------------------------------------------------------------------ +# 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 +} + +# ------------------------------------------------------------------------------ +# Compare installed semver against a version spec at the spec's precision. +# Returns: 0 if match (e.g. spec 8.0 matches installed 8.0.40), 1 otherwise +# Usage: version_matches_spec "8.0.40" "8.0" +# ------------------------------------------------------------------------------ +version_matches_spec() { + local installed="$1" + local spec="$2" + local spec_depth prefix i + local -a spec_parts installed_parts + + [[ -n "$installed" && -n "$spec" ]] || return 1 + + IFS='.' read -ra spec_parts <<<"$spec" + spec_depth=${#spec_parts[@]} + ((spec_depth > 0)) || return 1 + + if ((spec_depth == 1)); then + [[ "${installed%%.*}" == "$spec" ]] && return 0 + return 1 + fi + + IFS='.' read -ra installed_parts <<<"$installed" + prefix="" + for ((i = 0; i < spec_depth && i < ${#installed_parts[@]}; i++)); do + [[ -n "$prefix" ]] && prefix+="." + prefix+="${installed_parts[i]}" + done + [[ "$prefix" == "$spec" ]] +} + +# ------------------------------------------------------------------------------ +# 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 [[ ${#failed[@]} -lt ${#packages[@]} ]]; then + if [[ ${#failed[@]} -gt 0 ]]; then + msg_error "Partial install — failed packages: ${failed[*]}" + return 100 + 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 + # Keep data directory for safety (remove manually if needed) + # 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 + # Keep data directory for safety (remove manually if needed) + # 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 + # Keep data directory for safety (remove manually if needed) + # 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 match at the requested precision, no update needed + if version_matches_spec "$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) + + if [[ "$distro_id" == "debian" && "$(dpkg --print-architecture)" == "arm64" ]]; then + case "$distro_codename" in + trixie | forky) suite="noble" ;; + bookworm) suite="jammy" ;; + *) suite="noble" ;; + esac + distro_id="ubuntu" + repo_url="https://repo.mongodb.org/apt/ubuntu" + elif [[ "$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 </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 </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 </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 + ;; + + mysql) + if [[ -z "$gpg_key_url" ]]; then + msg_error "MySQL repository requires gpg_key_url" + return 65 + fi + + cleanup_old_repo_files "mysql" + + if ! download_gpg_key "$gpg_key_url" "/etc/apt/keyrings/mysql.gpg" "dearmor"; then + msg_error "Failed to import MySQL GPG key" + return 7 + fi + + local distro_codename suite component + distro_codename=$(get_os_info codename) + + if [[ "$distro_id" == "debian" ]]; then + case "$distro_codename" in + trixie | forky | sid) suite="bookworm" ;; + bookworm | bullseye) suite="$distro_codename" ;; + *) suite="bookworm" ;; + esac + else + suite=$(get_fallback_suite "$distro_id" "$distro_codename" "$repo_url") + fi + + case "$version" in + 8.4 | 8.4.*) component="mysql-8.4-lts" ;; + 8.0 | 8.0.*) component="mysql-8.0" ;; + *) component="mysql-${version}" ;; + esac + + cat </etc/apt/sources.list.d/mysql.sources +Types: deb +URIs: ${repo_url}/ +Suites: ${suite} +Components: ${component} +Architectures: $(dpkg --print-architecture) +Signed-By: /etc/apt/keyrings/mysql.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 </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 +# Appends to a shared list so existing EXIT traps are preserved. +# ------------------------------------------------------------------------------ +_tools_temp_dirs=() + +_tools_cleanup_temp_dirs() { + local d + for d in "${_tools_temp_dirs[@]}"; do + rm -rf "$d" 2>/dev/null || true + done +} + +_tools_register_temp_cleanup() { + [[ "${_TOOLS_TEMP_TRAP_SET:-}" == "1" ]] && return 0 + _TOOLS_TEMP_TRAP_SET=1 + local existing + existing=$(trap -p EXIT 2>/dev/null | sed -n "s/^trap -- '\\(.*\\)' EXIT/\1/p" || true) + if [[ -n "$existing" && "$existing" != "_tools_cleanup_temp_dirs" ]]; then + trap "_tools_cleanup_temp_dirs; ${existing}" EXIT ERR INT TERM + else + trap _tools_cleanup_temp_dirs EXIT ERR INT TERM + fi +} + +create_temp_dir() { + local tmp_dir + tmp_dir=$(mktemp -d) || return 1 + _tools_temp_dirs+=("$tmp_dir") + _tools_register_temp_cleanup + echo "$tmp_dir" +} + +# ------------------------------------------------------------------------------ +# create_backup [ ...] / 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 [ ...] +# - Copies each given file/directory into a persistent store at +# /opt/.backup, mirroring its absolute path inside the store, and +# records it in a manifest so restore_backup needs no arguments. +# - Idempotent per path: a path already recorded in the manifest (from this +# run or a previous failed one) is left untouched, keeping the +# last-known-good copy instead of overwriting it with now-partially-updated +# data on retry. Any requested path NOT yet in the manifest is backed up +# now and appended - so a manifest left incomplete by an interrupted prior +# run gets completed instead of silently missing paths on restore. +# - 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 script exits before the update runs against unprotected data (the +# paths already recorded are left in place, so a retry only redoes the one +# that failed). +# +# 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 ! mkdir -p "$store" || ! touch "$manifest"; then + msg_error "Backup failed: could not create store at ${store} - aborting update" + exit 1 + fi + + msg_info "Backing up data" + for path in "$@"; do + path="${path%/}" + if grep -qxF "$path" "$manifest" 2>/dev/null; then + continue + fi + if [[ ! -e "$path" ]]; then + msg_warn "Skipping backup of '${path}' (not found)" + continue + fi + if mountpoint -q "$path" 2>/dev/null; then + msg_warn "Skipping backup of '${path}' (is a mount point)" + continue + fi + dest="${store}/files${path}" + if ! mkdir -p "$(dirname "$dest")" || ! cp -a "$path" "$dest"; then + msg_error "Backup of '${path}' failed - aborting update" + 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 /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" ]] +} + +# ------------------------------------------------------------------------------ +# 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 the distro's own base sources file (debian.sources, + # ubuntu.sources, ...) + if [[ "$basename_file" == "$(get_os_info id).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 "$(get_os_info id).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 + + if ! $STD apt update; then + msg_error "apt update failed after adding repository: ${name}" + msg_error "Hint: Verify suite '${suite}' and URI '${repo_url}' are valid for this distribution." + return 100 + fi +} + +# ------------------------------------------------------------------------------ +# 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 + end_time=$(date +%s) + local duration=$((end_time - start_time)) + echo "${label} took ${duration}s" +} + +# ------------------------------------------------------------------------------ +# 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 +} + +# ------------------------------------------------------------------------------ +# _download_source_tarball [curl args...] +# +# Downloads a source .tar.gz to and verifies the gzip stream is complete +# before returning. Source forges (GitHub/GitLab/Codeberg) build these archives +# on the fly; for large repos the response is occasionally a truncated-but- +# cleanly-closed gzip that curl accepts as success (HTTP 200) and which then +# fails at extraction time. We validate with `gzip -t` and re-download on +# failure, and abort stalled transfers (--speed-time) so a hung generation +# retries instead of blocking for minutes. Extra args pass through to curl +# (e.g. auth headers like -H "PRIVATE-TOKEN: ..."). +# +# Returns: 0 on success, 250 on persistent failure. +# ------------------------------------------------------------------------------ +_download_source_tarball() { + local url="$1" dest="$2" + shift 2 + local attempt max=3 + for ((attempt = 1; attempt <= max; attempt++)); do + if curl --connect-timeout 15 --max-time 900 --speed-limit 1024 --speed-time 60 \ + -fsSL "$@" -o "$dest" "$url" && gzip -t "$dest" 2>/dev/null; then + return 0 + fi + rm -f "$dest" + ((attempt < max)) && { + msg_warn "Source archive download failed or incomplete (attempt ${attempt}/${max}), retrying..." + sleep $((attempt * 3)) + } + done + return 250 +} + +# ------------------------------------------------------------------------------ +# _deploy_source_tarball +# +# Shared tail for the *source tarball* modes of the fetch_and_deploy_* helpers +# (GitHub/GitLab/Codeberg archive tarballs that contain a single top-level +# directory). Extracts into , then copies the contents +# of that top-level directory into . +# +# - Honors CLEAN_INSTALL=1 (wipes first, dotfiles included, mount +# points preserved — back up config dotfiles like .env via create_backup +# and call restore_backup BEFORE any build step that sources them). +# - Does NOT own : the caller creates it and is responsible for its +# cleanup (typically via a RETURN trap on its tmpdir). +# - cp failures are non-fatal here, matching the previous inline behavior. +# +# Returns: 0 on success (or non-fatal cp failure), 251 on extraction failure. +# ------------------------------------------------------------------------------ +_deploy_source_tarball() { + local tarball="$1" target="$2" workdir="$3" + + mkdir -p "$target" + if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then + find "${target:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o -print0 | xargs -0 rm -rf -- + fi + + tar --no-same-owner -xzf "$tarball" -C "$workdir" || { + msg_error "Failed to extract tarball" + return 251 + } + + local unpack_dir + unpack_dir=$(find "$workdir" -mindepth 1 -maxdepth 1 -type d | head -n1) + + shopt -s dotglob nullglob + cp -r "$unpack_dir"/* "$target/" + shopt -u dotglob nullglob + return 0 +} + +# ------------------------------------------------------------------------------ +# _deploy_unpacked_archive +# +# Shared tail for the *prebuild* modes of the fetch_and_deploy_*_release helpers +# (release assets shipped as .zip / .tar.* / .tgz / .txz). Extracts the archive +# into , then copies its payload into . If the archive contains +# a single top-level directory, that directory is stripped (its contents land +# directly in ); otherwise the archive contents are copied as-is. +# +# - Honors CLEAN_INSTALL=1 (wipes first, dotfiles included, mount +# points preserved — back up config dotfiles like .env via create_backup +# and call restore_backup BEFORE any build step that sources them). +# - Does NOT own : the caller creates it and cleans it up. +# +# Returns: 0 on success, 65 on unsupported format, 251 on extraction failure, +# 252 on copy failure / empty archive. +# ------------------------------------------------------------------------------ +_deploy_unpacked_archive() { + local archive="$1" target="$2" workdir="$3" + local filename="${archive##*/}" + + if [[ "$filename" == *.zip ]]; then + ensure_dependencies unzip + unzip -q "$archive" -d "$workdir" || { + msg_error "Failed to extract ZIP archive" + return 251 + } + elif [[ "$filename" == *.tar.* || "$filename" == *.tgz || "$filename" == *.txz ]]; then + tar --no-same-owner -xf "$archive" -C "$workdir" || { + msg_error "Failed to extract TAR archive" + return 251 + } + elif [[ "$filename" == *.7z ]]; then + if [[ -f /etc/alpine-release ]]; then + ensure_dependencies 7zip + else + ensure_dependencies p7zip-full + fi + 7z x -y -o"$workdir" "$archive" >/dev/null 2>&1 || { + msg_error "Failed to extract 7z archive" + return 251 + } + else + msg_error "Unsupported archive format: $filename" + return 65 + fi + + local top_entries inner_dir source_dir + top_entries=$(find "$workdir" -mindepth 1 -maxdepth 1) + if [[ "$(echo "$top_entries" | wc -l)" -eq 1 && -d "$top_entries" ]]; then + inner_dir="$top_entries" + source_dir="$inner_dir" + else + source_dir="$workdir" + fi + + shopt -s dotglob nullglob + if ! compgen -G "$source_dir/*" >/dev/null; then + if [[ -n "$inner_dir" ]]; then + msg_error "Inner directory is empty: $inner_dir" + else + msg_error "Unpacked archive is empty" + fi + shopt -u dotglob nullglob + return 252 + fi + + # is only touched once the payload is known good. Wiping earlier left + # CLEAN_INSTALL callers with an empty target directory whenever the download + # was truncated, the archive was unreadable, or it unpacked to nothing. + mkdir -p "$target" + if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then + find "${target:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o -print0 | xargs -0 rm -rf -- + fi + + if ! cp -r "$source_dir"/* "$target/"; then + if [[ -n "$inner_dir" ]]; then + msg_error "Failed to copy contents from $inner_dir to $target" + else + msg_error "Failed to copy contents to $target" + fi + shopt -u dotglob nullglob + return 252 + fi + shopt -u dotglob nullglob + return 0 +} + +# ------------------------------------------------------------------------------ +# 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 ~/. +# +# 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 ~/.) +# $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 + trap 'rm -rf "$tmpdir"; trap - RETURN' RETURN + 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_source_tarball "$tarball_url" "$tmpdir/$filename" || { + msg_error "Download failed: $tarball_url" + return 7 + } + + _deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 251 + + echo "$version" >"$version_file" + msg_ok "Deployed ${app} ${version} to ${target}" + return 0 +} + +# ------------------------------------------------------------------------------ +# Get the latest GitLab repository tag matching a glob pattern. +# +# Description: +# - Queries the GitLab repository tags API (up to 100 tags per page) +# - Filters tag names against a shell glob pattern (e.g. "web-v*" or "mobile-v*") +# - Always excludes pre-release tags (those containing alpha, beta, or rc) +# - Sorts matching tags with `sort -V` and returns the highest one +# - Supports GITLAB_TOKEN for private/rate-limited projects +# +# Usage: +# get_latest_gl_tag "owner/repo" "web-v*" +# get_latest_gl_tag "owner/repo" "mobile-v*" +# get_latest_gl_tag "owner/repo" # returns newest tag (no filter) +# +# Arguments: +# $1 - GitLab repo path (namespace/project, e.g. "mygroup/myapp") +# $2 - Optional glob pattern to filter tag names (e.g. "web-v*") +# +# Returns: +# Latest matching tag name on stdout, or non-zero on failure +# ------------------------------------------------------------------------------ +get_latest_gl_tag() { + local repo="$1" + local pattern="${2:-}" + + local repo_encoded + repo_encoded=$(printf '%s' "$repo" | sed 's|/|%2F|g') + + local gitlab_url="${GITLAB_URL:-https://gitlab.com}" + local api_base="${gitlab_url}/api/v4/projects/${repo_encoded}/repository/tags" + local api_timeout="--connect-timeout 10 --max-time 60" + + local header=() + [[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN") + + # If a pattern is given, pass it as a regex search to reduce server-side results. + # GitLab ?search= supports anchored regex; convert leading glob prefix to regex anchor. + local search_param="" + if [[ -n "$pattern" ]]; then + # Strip trailing wildcard for the search hint (server-side prefix filter). + local prefix="${pattern%%\**}" + [[ -n "$prefix" ]] && search_param="?search=^${prefix}&per_page=100" || search_param="?per_page=100" + else + search_param="?per_page=100" + fi + + local temp_file + temp_file=$(mktemp) || return 1 + + local http_code + http_code=$(curl $api_timeout -sSL -w "%{http_code}" -o "$temp_file" \ + "${header[@]}" "${api_base}${search_param}" 2>/dev/null) || true + + if [[ "$http_code" != "200" ]]; then + rm -f "$temp_file" + msg_error "GitLab tags API returned HTTP $http_code for $repo" + return 22 + fi + + local tag="" + if [[ -n "$pattern" ]]; then + # Client-side glob filter + pre-release exclusion, then version-sort to pick the highest match. + tag=$(jq -r '.[].name' "$temp_file" 2>/dev/null | while IFS= read -r t; do + case "$t" in + *alpha* | *beta* | *rc*) continue ;; + $pattern) echo "$t" ;; + esac + done | sort -V | tail -n1) + else + # No pattern: skip pre-release tags, take the first remaining (newest) one. + tag=$(jq -r '.[].name' "$temp_file" 2>/dev/null | + grep -Eiv '(alpha|beta|rc)' | + head -n1) + fi + + rm -f "$temp_file" + + if [[ -z "$tag" ]]; then + msg_error "No tags matching '${pattern:-*}' found for ${repo}" + return 250 + fi + + echo "$tag" +} + +# ------------------------------------------------------------------------------ +# Fetches and deploys a GitLab tag-based source tarball. +# +# Description: +# - Resolves the latest tag matching the given glob pattern via get_latest_gl_tag +# (or uses the exact tag if one is provided instead of "latest") +# - Downloads the GitLab source tarball for that tag +# - Extracts it to the target directory +# - Writes the resolved tag to ~/. for update-checking +# +# Usage: +# fetch_and_deploy_gl_tag "myapp" "mygroup/myrepo" "web-v*" +# fetch_and_deploy_gl_tag "myapp" "mygroup/myrepo" "mobile-v*" "/opt/myapp" +# fetch_and_deploy_gl_tag "myapp" "mygroup/myrepo" "v*" # any v-tag +# fetch_and_deploy_gl_tag "myapp" "mygroup/myrepo" "web-v3.0*" # narrow version range +# +# Arguments: +# $1 - App name (used for version file ~/. and lowercase tarball name) +# $2 - GitLab repo path (namespace/project, e.g. "mygroup/myapp") +# $3 - Tag pattern: glob (e.g. "web-v*") or exact tag (e.g. "web-v3.0.0"). +# Use "latest" to fetch the single newest tag with no pattern filter. +# $4 - Target directory (default: /opt/$app) +# +# Notes: +# - Supports CLEAN_INSTALL=1 to wipe target before extracting +# - Supports GITLAB_TOKEN for private/rate-limited projects +# - Supports GITLAB_URL for self-hosted GitLab (default: https://gitlab.com) +# - For repos that only publish tags, not formal GitLab Releases +# (use fetch_and_deploy_gl_release for proper Releases with assets) +# ------------------------------------------------------------------------------ +fetch_and_deploy_gl_tag() { + local app="$1" + local repo="$2" + local tag_pattern="${3:-latest}" + local target="${4:-/opt/$app}" + + local app_lc="" + 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 header=() + [[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN") + + # Resolve the tag: if caller passed a glob/latest, query the API. + # If caller passed an exact tag (no wildcards), use it directly. + local resolved_tag="$tag_pattern" + if [[ "$tag_pattern" == "latest" || "$tag_pattern" == *"*"* || "$tag_pattern" == *"?"* ]]; then + local glob_arg="" + [[ "$tag_pattern" != "latest" ]] && glob_arg="$tag_pattern" + resolved_tag=$(get_latest_gl_tag "$repo" "$glob_arg") || { + msg_error "Failed to determine latest tag matching '${tag_pattern}' for ${repo}" + return 250 + } + fi + + local current_version="" + [[ -f "$version_file" ]] && current_version=$(<"$version_file") + + if [[ "$current_version" == "$resolved_tag" ]]; then + msg_ok "$app is already up-to-date ($resolved_tag)" + return 0 + fi + + local repo_encoded + repo_encoded=$(printf '%s' "$repo" | sed 's|/|%2F|g') + + # GitLab source tarball URL (no release needed, works for any tag). + local gitlab_url="${GITLAB_URL:-https://gitlab.com}" + local version_safe="${resolved_tag//\//-}" + local tarball_url="${gitlab_url}/${repo}/-/archive/${resolved_tag}/${app_lc}-${version_safe}.tar.gz" + + local tmpdir + tmpdir=$(mktemp -d) || return 1 + trap 'rm -rf "$tmpdir"; trap - RETURN' RETURN + local filename="${app_lc}-${version_safe}.tar.gz" + + msg_info "Fetching GitLab tag: ${app} (${resolved_tag})" + + _download_source_tarball "$tarball_url" "$tmpdir/$filename" "${header[@]}" || { + msg_error "Download failed: $tarball_url" + return 7 + } + + _deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 251 + + echo "$resolved_tag" >"$version_file" + msg_ok "Deployed ${app} ${resolved_tag} 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 (~/.) +# - 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 (~/.) +# - 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 +# ------------------------------------------------------------------------------ +TOOLS_GH_REL_JSON="" + +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 tag_prefix="${5:-}" # optional tag prefix filter (e.g. web-v2) + 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 + + local gh_check_json="" + gh_check_json=$(mktemp /tmp/tools-gh-check-XXXXXX) || return 73 + trap 'rm -f "${gh_check_json:-}"; trap - RETURN' RETURN + + # 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 "$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 "$gh_check_json" ]]; then + releases_json="[$(<"$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 "$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 "$gh_check_json" + return 22 + fi + rm -f "$gh_check_json" + fi + + if [[ -z "$pinned_version_in" && -z "$tag_prefix" ]]; then + http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o "$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 "$gh_check_json" ]]; then + releases_json="[$(<"$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 "$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 "$gh_check_json" + return 22 + fi + rm -f "$gh_check_json" + fi + + # If no releases yet (pinned version, tag prefix, OR /latest failed), fetch up to 100 + if [[ -z "$releases_json" ]]; then + http_code=$(curl -sSL --max-time 20 -w "%{http_code}" -o "$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 "$gh_check_json" ]]; then + releases_json=$(<"$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 "$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 "$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 "$gh_check_json" + return 7 + else + msg_error "Unable to fetch releases for ${app} (HTTP ${http_code})" + rm -f "$gh_check_json" + return 22 + fi + rm -f "$gh_check_json" + fi + + if [[ -n "$tag_prefix" ]]; then + mapfile -t raw_tags < <(jq -r --arg p "$tag_prefix" \ + '.[] | select(.draft==false and .prerelease==false) | select(.tag_name | startswith($p)) | .tag_name' <<<"$releases_json") + else + mapfile -t raw_tags < <(jq -r '.[] | select(.draft==false and .prerelease==false) | .tag_name' <<<"$releases_json") + fi + if ((${#raw_tags[@]} == 0)); then + if [[ -n "$tag_prefix" ]]; then + msg_error "No stable releases matching prefix '${tag_prefix}' found for ${app}" + else + msg_error "No stable releases found for ${app}" + fi + 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 (~/.) +# - 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 +# +# 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 ~/. +# +# 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/) +# $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 + trap 'rm -rf "$tmpdir"; trap - RETURN' RETURN + + 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 _download_source_tarball "$archive_url" "$tmpdir/$filename"; then + download_success=true + fi + + if [[ "$download_success" != "true" ]]; then + msg_error "Download failed for $app ($tag_name)" + return 250 + fi + + _deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 251 + + echo "$version" >"$version_file" + msg_ok "Deployed: $app ($version)" + 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 codeberg_rel_json + codeberg_rel_json=$(mktemp /tmp/tools-codeberg-rel-XXXXXX) || return 73 + trap 'rm -f "$codeberg_rel_json"; rm -rf "${tmpdir:-}" "${unpack_tmp:-}"; trap - RETURN' RETURN + + 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 "$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=$(<"$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 _download_source_tarball "$archive_url" "$tmpdir/$filename"; then + download_success=true + fi + + if [[ "$download_success" != "true" ]]; then + msg_error "Download failed for $app ($tag_name)" + return 250 + fi + + _deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 251 + + ### 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" + return 252 + fi + + filename="${url_match##*/}" + curl_download "$tmpdir/$filename" "$url_match" || { + msg_error "Download failed: $url_match" + return 250 + } + + chmod 644 "$tmpdir/$filename" + $STD apt install -y "$tmpdir/$filename" || { + $STD dpkg -i "$tmpdir/$filename" || { + _diagnose_deb_failure "$tmpdir/$filename" + 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)" + 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" + return 252 + } + + filename="${asset_url##*/}" + curl_download "$tmpdir/$filename" "$asset_url" || { + msg_error "Download failed: $asset_url" + return 250 + } + + local unpack_tmp + unpack_tmp=$(mktemp -d) + _deploy_unpacked_archive "$tmpdir/$filename" "$target" "$unpack_tmp" || return + + ### Singlefile Mode ### + elif [[ "$mode" == "singlefile" ]]; then + local pattern="${6%\"}" + pattern="${pattern#\"}" + [[ -z "$pattern" ]] && { + msg_error "Mode 'singlefile' requires 6th parameter (asset filename pattern)" + 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" + 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" + return 250 + } + + if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then + chmod +x "$target/$target_file" + fi + + else + msg_error "Unknown mode: $mode" + return 65 + fi + + echo "$version" >"$version_file" + msg_ok "Deployed: $app ($version)" +} + +# ------------------------------------------------------------------------------ +# 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 ~/. +# +# 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/) +# $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 /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 "$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=$(<"$gh_rel_json") + if [[ "$version" == "latest" && -n "$tag_prefix" ]]; then + json=$(echo "$json" | jq --arg p "$tag_prefix" \ + '[.[] | select(.draft==false and .prerelease==false) | select(.tag_name | startswith($p))][0] // empty') + if [[ -z "$json" || "$json" == "null" ]]; then + msg_error "No stable release matching prefix '${tag_prefix}' found for $repo on GitHub" + return 1 + fi + fi + 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 + trap 'rm -rf "$tmpdir" "${unpack_tmp:-}"; trap - RETURN' RETURN + 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" + + _download_source_tarball "$direct_tarball_url" "$tmpdir/$filename" || { + msg_error "Download failed: $direct_tarball_url" + return 250 + } + + _deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 251 + + ### 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" + return 252 + fi + + filename="${url_match##*/}" + curl_download "$tmpdir/$filename" "$url_match" || { + msg_error "Download failed: $url_match" + 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" + 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)" + 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" + return 252 + } + + filename="${asset_url##*/}" + curl_download "$tmpdir/$filename" "$asset_url" || { + msg_error "Download failed: $asset_url" + return 250 + } + + local unpack_tmp + unpack_tmp=$(mktemp -d) + _deploy_unpacked_archive "$tmpdir/$filename" "$target" "$unpack_tmp" || return + + ### Singlefile Mode ### + elif [[ "$mode" == "singlefile" ]]; then + local pattern="${6%\"}" + pattern="${pattern#\"}" + [[ -z "$pattern" ]] && { + msg_error "Mode 'singlefile' requires 6th parameter (asset filename pattern)" + 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" + 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" + return 250 + } + + if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then + chmod +x "$target/$target_file" + fi + + else + msg_error "Unknown mode: $mode" + return 65 + fi + + echo "$version" >"$version_file" + msg_ok "Deployed: $app ($version)" +} + +# ------------------------------------------------------------------------------ +# 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 +# - Updates running containers interactively +# - Cleans up legacy repository files +# +# Usage: +# setup_docker # Uses official Docker repo (recommended) +# USE_DOCKER_REPO=false setup_docker # Uses distro docker.io package +# DOCKER_LOG_DRIVER="json-file" setup_docker +# +# Variables: +# USE_DOCKER_REPO - Set to "false" to use distro docker.io package +# (default: true, uses official Docker repository) +# DOCKER_LOG_DRIVER - Log driver (optional, default: "journald") +# DOCKER_SKIP_UPDATES - Skip container update check (optional, "true" to skip) +# +# Features: +# - Uses official Docker repository by default +# - Migrates from get.docker.com to repository-based installation +# - Updates Docker Engine if newer version available +# - Interactive per-container update prompt (Y/N, 60 s auto-no) +# - Compose-managed containers: full restart via docker compose +# - Standalone containers: image pull only, no destructive stop/rm +# - Portainer is no longer managed here; use tools/addon/portainer.sh +# - Set DOCKER_NONINTERACTIVE=1 to skip interactive prompts (CI/unattended) +# ------------------------------------------------------------------------------ +_docker_is_noninteractive() { + [[ "${DOCKER_NONINTERACTIVE:-}" == "1" || "${DOCKER_NONINTERACTIVE:-}" == "true" || "${DOCKER_NONINTERACTIVE:-}" == "TRUE" ]] || [[ ! -t 0 ]] +} + +# ------------------------------------------------------------------------------ +# ensure_docker() +# +# - Docker precondition for addon scripts that ship a compose stack +# - Reuses an existing installation, otherwise offers to install Docker +# (Debian/Ubuntu via setup_docker, Alpine via apk) +# - Aborts when the user declines or Compose is unavailable afterwards +# ------------------------------------------------------------------------------ +ensure_docker() { + if command -v docker &>/dev/null; then + msg_ok "Docker $(docker --version | cut -d' ' -f3 | tr -d ',') is available" + if docker compose version &>/dev/null; then + msg_ok "Docker Compose is available" + return 0 + fi + msg_error "Docker Compose plugin is not available. Please install it." + exit 237 + fi + + msg_warn "Docker is not installed." + echo -n "${TAB:- }Install Docker now? (y/N): " + local install_docker_prompt + read -r install_docker_prompt + if [[ ! "${install_docker_prompt,,}" =~ ^(y|yes)$ ]]; then + msg_error "Docker is required for ${APP:-this script}. Exiting." + exit 254 + fi + + if is_alpine; then + msg_info "Installing Docker" + $STD apk add --no-cache docker docker-cli-compose + $STD rc-update add docker default + $STD rc-service docker start + msg_ok "Installed Docker" + else + setup_docker || { + msg_error "Docker installation failed." + exit 237 + } + fi + + if ! command -v docker &>/dev/null || ! docker compose version &>/dev/null; then + msg_error "Docker or the Compose plugin is still unavailable after installation." + exit 237 + fi +} + +setup_docker() { + local docker_installed=false + local USE_DOCKER_REPO="${USE_DOCKER_REPO:-true}" + + # 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 + + # Scenario 1: Use distro repository (opt-out via USE_DOCKER_REPO=false) + 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 </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 </etc/docker/daemon.json +{ + "log-driver": "$log_driver" +} +EOF + fi + + # Enable and start Docker + systemctl enable -q --now docker + fi + + # Container Update Check + # - Skipped entirely when running unattended / non-interactive + # - Lists containers with a newer image available and the command to update + # them; does not pull/recreate anything itself + if [ "$docker_installed" = true ] && ! _docker_is_noninteractive; then + msg_info "Checking for container updates" + + local name image compose_workdir compose_service current_digest latest_digest + local compose_updates=() + local standalone_updates=() + + while IFS= read -r line; do + name=$(echo "$line" | awk '{print $1}') + image=$(echo "$line" | awk '{print $2}') + + # Portainer is managed by its own addon script, not this generic loop + [[ "$name" == "portainer" || "$name" == "portainer_agent" ]] && continue + + current_digest=$(docker inspect "$name" --format='{{.Image}}' 2>/dev/null | cut -d':' -f2 | cut -c1-12) + docker pull "$image" >/dev/null 2>&1 || continue + latest_digest=$(docker inspect "$image" --format='{{.Id}}' 2>/dev/null | cut -d':' -f2 | cut -c1-12) + [[ -z "$latest_digest" || "$current_digest" == "$latest_digest" ]] && continue + + compose_workdir=$(docker inspect "$name" \ + --format='{{index .Config.Labels "com.docker.compose.project.working_dir"}}' 2>/dev/null || true) + compose_service=$(docker inspect "$name" \ + --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || true) + + if [[ -n "$compose_workdir" && -n "$compose_service" && -d "$compose_workdir" ]]; then + compose_updates+=("${name}|${image}|${compose_workdir}|${compose_service}") + else + standalone_updates+=("${name}|${image}") + fi + done < <(docker ps --format '{{.Names}} {{.Image}}') + + # Stop spinner before any interactive prompt + stop_spinner + + if [[ ${#compose_updates[@]} -eq 0 && ${#standalone_updates[@]} -eq 0 ]]; then + msg_ok "All containers are up-to-date" + else + msg_warn "Updates available (not applied automatically):" + for entry in "${compose_updates[@]}"; do + IFS='|' read -r name image compose_workdir compose_service <<<"$entry" + echo -e "${TAB3}${name} (${image}) — update via: cd ${compose_workdir} && docker compose pull ${compose_service} && docker compose up -d ${compose_service}" + done + for entry in "${standalone_updates[@]}"; do + IFS='|' read -r name image <<<"$entry" + echo -e "${TAB3}${name} (${image}) — update via: docker pull ${image} && docker restart ${name}" + done + 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 = 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 /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 \ + mesa-vulkan-drivers \ + vulkan-tools \ + 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 TOOLS_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 \ + mesa-vulkan-drivers \ + vulkan-tools \ + 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 \ + mesa-vulkan-drivers \ + vulkan-tools \ + 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 TOOLS_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 \ + mesa-vulkan-drivers \ + vulkan-tools \ + 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 </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 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 + + # Fetch and deploy source tarball from GitHub Releases + fetch_and_deploy_gh_release "imagemagick" "ImageMagick/ImageMagick" "tarball" || { + msg_error "Failed to download ImageMagick source from GitHub" + msg_error "Hint: Check connectivity to github.com/ImageMagick/ImageMagick" + return 250 + } + + cd /opt/imagemagick || { + msg_error "Failed to enter ImageMagick source directory" + return 251 + } + + $STD ./configure --disable-static || { + msg_error "ImageMagick configure failed" + return 150 + } + $STD make -j"$(nproc)" || { + msg_error "ImageMagick compilation failed" + return 150 + } + $STD make install || { + msg_error "ImageMagick installation failed" + return 150 + } + $STD ldconfig /usr/local/lib + + if [[ ! -x "$BINARY_PATH" ]]; then + msg_error "ImageMagick installation failed" + return 150 + fi + + local FINAL_VERSION + FINAL_VERSION=$("$BINARY_PATH" -version | awk '/^Version/ {print $3}') + 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 <"$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 </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" ]] && version_matches_spec "$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" ]] && ! version_matches_spec "$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" + + # Use --defaults-extra-file to pass credentials safely and escape identifiers + # to prevent SQL injection via DB name / user / password + $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 +} + +# ------------------------------------------------------------------------------ +# Creates a MySQL database and user (for apps that require MySQL, not MariaDB). +# +# Description: +# - Creates database, user and grants using the mysql root socket login +# - The user is created with host '%' because MySQL treats 'localhost' +# (socket) and '127.0.0.1' (TCP) as distinct hosts; apps connecting over +# TCP to 127.0.0.1 would not match a 'localhost' account. mysql-server +# binds to 127.0.0.1 by default, so '%' stays local-only. +# +# Variables: +# MYSQL_DB_NAME - Database name (required) +# MYSQL_DB_USER - Database user (required) +# MYSQL_DB_PASS - Password (optional, generated if unset) +# +# Exports: +# MYSQL_DB_NAME, MYSQL_DB_USER, MYSQL_DB_PASS +# +# Example: +# MYSQL_DB_NAME="fleet" MYSQL_DB_USER="fleet" setup_mysql_db +# ------------------------------------------------------------------------------ +setup_mysql_db() { + if [[ -z "${MYSQL_DB_NAME:-}" || -z "${MYSQL_DB_USER:-}" ]]; then + msg_error "MYSQL_DB_NAME and MYSQL_DB_USER must be set before calling setup_mysql_db" + return 65 + fi + + if [[ -z "${MYSQL_DB_PASS:-}" ]]; then + MYSQL_DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13) + fi + + msg_info "Setting up MySQL Database" + + $STD mysql -u root -e "CREATE DATABASE \`${MYSQL_DB_NAME//\`/\`\`}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + $STD mysql -u root -e "CREATE USER '${MYSQL_DB_USER//\'/\'\'}'@'%' IDENTIFIED BY '${MYSQL_DB_PASS//\'/\'\'}';" + $STD mysql -u root -e "GRANT ALL ON \`${MYSQL_DB_NAME//\`/\`\`}\`.* TO '${MYSQL_DB_USER//\'/\'\'}'@'%';" + $STD mysql -u root -e "FLUSH PRIVILEGES;" + + local app_name="${APPLICATION,,}" + local CREDS_FILE="${MYSQL_DB_CREDS_FILE:-${HOME}/${app_name}.creds}" + { + echo "MySQL Credentials" + echo "Database: $MYSQL_DB_NAME" + echo "User: $MYSQL_DB_USER" + echo "Password: $MYSQL_DB_PASS" + } >>"$CREDS_FILE" + + msg_ok "Set up MySQL Database" + + export MYSQL_DB_NAME + export MYSQL_DB_USER + export MYSQL_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 changes its on-disk DB format 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 connection + storage paths for the in-place (dumpless) upgrade + local MEILI_HOST MEILI_PORT MEILI_DB_PATH + MEILI_HOST="${MEILISEARCH_HOST:-127.0.0.1}" + MEILI_PORT="${MEILISEARCH_PORT:-7700}" + 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}" + + systemctl stop meilisearch + + # Safety backup of the data dir before any in-place migration. + # Dumpless upgrade mutates the DB in place and cannot be rolled back on + # failure, so keep a tarball to restore from. (No master_key/API needed.) + local MEILI_BACKUP="" + if [[ "$NEEDS_MIGRATION" == "true" ]]; then + MEILI_BACKUP="/var/lib/meilisearch/pre-upgrade-${CURRENT_VERSION}.tar.gz" + msg_info "Backing up data dir before upgrade → ${MEILI_BACKUP}" + if ! tar -czf "$MEILI_BACKUP" -C "$MEILI_DB_PATH" . 2>/dev/null; then + msg_error "MeiliSearch data backup failed — aborting upgrade to avoid data loss" + systemctl start meilisearch + return 100 + fi + msg_ok "Backup created: ${MEILI_BACKUP}" + fi + + # Replace the binary + if [[ "$(arch_resolve)" == "arm64" ]]; then + fetch_and_deploy_gh_release "meilisearch" "meilisearch/meilisearch" "singlefile" "latest" "/usr/bin" "meilisearch-linux-aarch64" + else + fetch_and_deploy_gh_release "meilisearch" "meilisearch/meilisearch" "binary" + fi + + if [[ "$NEEDS_MIGRATION" == "true" ]]; then + # One-time in-place migration via --experimental-dumpless-upgrade (replaced with --upgrade-db) + # Meilisearch migrates the on-disk DB during this startup; subsequent + # normal (systemd) starts run without the flag. + msg_info "Migrating data in place (dumpless upgrade ${CURRENT_VERSION} → ${NEW_VERSION})" + /usr/bin/meilisearch --config-file-path /etc/meilisearch.toml --upgrade-db >/dev/null 2>&1 & + local MEILI_PID=$! + + local MAX_WAIT=300 + local WAITED=0 + local MIGRATED=false + while [[ $WAITED -lt $MAX_WAIT ]]; do + if curl -sf "http://${MEILI_HOST}:${MEILI_PORT}/health" &>/dev/null; then + MIGRATED=true + break + fi + # Bail early if the migration process died + if ! kill -0 $MEILI_PID 2>/dev/null; then + msg_warn "MeiliSearch process exited during migration" + break + fi + sleep 3 + WAITED=$((WAITED + 3)) + done + + # Stop the one-shot migration process and hand over to systemd + kill $MEILI_PID 2>/dev/null || true + wait $MEILI_PID 2>/dev/null || true + sleep 2 + systemctl start meilisearch + + if [[ "$MIGRATED" == "true" ]] && systemctl is-active --quiet meilisearch; then + msg_ok "MeiliSearch migrated successfully (backup kept at ${MEILI_BACKUP})" + else + msg_error "MeiliSearch migration failed. Restore with: systemctl stop meilisearch; rm -rf ${MEILI_DB_PATH:?}/*; tar -xzf ${MEILI_BACKUP} -C ${MEILI_DB_PATH}; then reinstall the previous version" + fi + else + systemctl start meilisearch + fi + + msg_ok "Updated MeiliSearch" + fi + return 0 + fi + + # Fresh install + msg_info "Setup MeiliSearch" + + # Install binary (meilisearch publishes only an amd64 .deb; arm64 uses the raw linux-aarch64 binary) + if [[ "$(arch_resolve)" == "arm64" ]]; then + fetch_and_deploy_gh_release "meilisearch" "meilisearch/meilisearch" "singlefile" "latest" "/usr/bin" "meilisearch-linux-aarch64" || { + msg_error "Failed to install MeiliSearch binary" + return 250 + } + else + fetch_and_deploy_gh_release "meilisearch" "meilisearch/meilisearch" "binary" || { + msg_error "Failed to install MeiliSearch binary" + return 250 + } + fi + + # 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 </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 CPU requirements: AVX on x86_64, NEON/ASIMD on arm64 + case "$(uname -m)" in + aarch64) + if ! grep -qm1 'asimd' /proc/cpuinfo; then + msg_error "MongoDB ${MONGO_VERSION} requires ARMv8.2-A support, which is not available on this system." + return 236 + fi + ;; + *) + 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 + ;; + esac + + 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 + + local KERNEL_VERSION MONGO_MAJOR + KERNEL_VERSION=$(uname -r | cut -d- -f1) + MONGO_MAJOR="${MONGO_VERSION%%.*}" + if ((MONGO_MAJOR >= 8)) && [[ "$(printf '%s\n' "6.19" "$KERNEL_VERSION" | sort -V | head -n1)" == "6.19" ]]; then + mkdir -p /etc/systemd/system/mongod.service.d + cat </etc/systemd/system/mongod.service.d/rseq.conf +[Service] +Environment=GLIBC_TUNABLES=glibc.pthread.rseq=1 +EOF + systemctl daemon-reload + fi + + $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" ]] && version_matches_spec "$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" ]] && ! version_matches_spec "$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 </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 global modules (e.g. "yarn,corepack,pnpm@10") +# NODE_COREPACK_ENABLE - Run corepack enable after install (default: 1 when corepack is used) +# ------------------------------------------------------------------------------ + +setup_nodejs() { + local NODE_VERSION="${NODE_VERSION:-24}" + local NODE_MODULE="${NODE_MODULE:-}" + local NODE_COREPACK_ENABLE="${NODE_COREPACK_ENABLE:-1}" + local node_major="${NODE_VERSION%%.*}" + local wants_corepack=0 + local node_setup_ok_msg="" + + # Corepack must run fully non-interactive. Without this it prints + # "Corepack is about to download X. Do you want to continue? [Y/n]" and blocks + # the whole install waiting for keyboard input - both here and in the calling + # script's later `corepack prepare` / `corepack ` calls, which run in this + # same shell and inherit the export. + export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + + # 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 + + 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" + node_setup_ok_msg="Upgrade Node.js to $NODE_VERSION" + remove_old_tool_version "nodejs" + else + msg_info "Setup Node.js $NODE_VERSION" + node_setup_ok_msg="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 + msg_ok "Removed Debian-packaged Node.js" + 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 + + cache_installed_version "nodejs" "$NODE_VERSION" + msg_ok "$node_setup_ok_msg" + fi + + # Node 22.22.2 bundles npm 10.9.7 which cannot self-upgrade (nodejs/node#62425) + if [[ "$NODE_VERSION" == "22" && "$(npm -v 2>/dev/null)" == "10.9.7" ]]; then + msg_info "Updating npm (Node 22 regression fix)" + $STD npm install -g npm@10.9.8 2>/dev/null || true + if $STD npm install -g npm@latest 2>/dev/null; then + msg_ok "Updated npm ($(npm -v))" + else + msg_warn "npm update failed on Node 22.22.2" + fi + fi + + if [[ "$(npm -v 2>/dev/null | cut -d. -f1)" -ge 11 ]]; then + $STD npm config set dangerously-allow-all-scripts true --location=global 2>/dev/null || true + 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" ]] || ((node_major >= 25)); then + if ((node_major >= 25)) && [[ ",${NODE_MODULE}," != *",corepack,"* ]] && [[ "$NODE_MODULE" != corepack* ]]; then + NODE_MODULE="${NODE_MODULE:+$NODE_MODULE,}corepack" + fi + + IFS=',' read -ra MODULES <<<"$NODE_MODULE" + + local corepack_spec="corepack@latest" + for i in "${!MODULES[@]}"; do + if [[ "${MODULES[$i]}" == "corepack" || "${MODULES[$i]}" == corepack@* ]]; then + wants_corepack=1 + [[ "${MODULES[$i]}" == corepack@* ]] && corepack_spec="${MODULES[$i]}" + fi + if [[ "${MODULES[$i]}" == "pnpm" ]]; then + MODULES[$i]="pnpm@^10" + fi + done + + # When corepack is requested, install and enable it FIRST. The corepack npm + # package owns the global yarn/pnpm/pnpx/yarnpkg bin shims, so a second + # `npm install -g yarn`/`pnpm` collides on /usr/bin/ (EEXIST). With + # corepack ready we provision those package managers THROUGH corepack instead. + local corepack_ready=0 + if ((wants_corepack)); then + msg_info "Installing corepack" + if $STD npm install -g "$corepack_spec" 2>/dev/null || + $STD npm install -g --force "$corepack_spec" 2>/dev/null; then + msg_ok "Installed corepack" + else + msg_warn "Failed to install corepack" + fi + if [[ "$NODE_COREPACK_ENABLE" == "1" ]] && command -v corepack >/dev/null 2>&1; then + msg_info "Enabling corepack" + if $STD corepack enable 2>/dev/null; then + corepack_ready=1 + msg_ok "Enabled corepack" + else + msg_warn "corepack enable failed" + fi + fi + fi + + local failed_modules=0 + for mod in "${MODULES[@]}"; do + # corepack itself is already handled above + if [[ "$mod" == "corepack" || "$mod" == corepack@* ]]; then + continue + fi + + 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 + + # Provision pnpm/yarn through corepack when it is active, so we never fight + # corepack over the /usr/bin/{yarn,pnpm} shim locations it owns. + if ((corepack_ready)) && [[ "$MODULE_NAME" == "pnpm" || "$MODULE_NAME" == "yarn" ]]; then + local corepack_pkg="$MODULE_NAME" + [[ "$MODULE_REQ_VERSION" != "latest" ]] && corepack_pkg="${MODULE_NAME}@${MODULE_REQ_VERSION}" + msg_info "Provisioning $MODULE_NAME via corepack" + if $STD corepack prepare "$corepack_pkg" --activate 2>/dev/null || command -v "$MODULE_NAME" >/dev/null 2>&1; then + msg_ok "Provisioned $MODULE_NAME via corepack" + else + msg_warn "Failed to provision $MODULE_NAME via corepack" + ((failed_modules++)) || true + fi + continue + fi + + # For the npm-global path, drop any corepack-provided shim first so the + # bin link can be created without an EEXIST collision. + if [[ "$MODULE_NAME" == "pnpm" || "$MODULE_NAME" == "yarn" || "$MODULE_NAME" == "yarnpkg" ]]; then + rm -f /usr/bin/"$MODULE_NAME" /usr/local/bin/"$MODULE_NAME" 2>/dev/null || true + 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 to v$MODULE_REQ_VERSION" + # Retry with --force to overwrite corepack-provided shims (pnpm/yarn), which now + # ship with recent corepack and cause EEXIST on /usr/bin/ + if $STD npm install -g "${MODULE_NAME}@${MODULE_REQ_VERSION}" 2>/dev/null || + $STD npm install -g --force "${MODULE_NAME}@${MODULE_REQ_VERSION}" 2>/dev/null; then + msg_ok "Updated $MODULE_NAME" + else + msg_warn "Failed to update $MODULE_NAME to version $MODULE_REQ_VERSION" + ((failed_modules++)) || true + 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 || + $STD npm install -g --force "${MODULE_NAME}@latest" 2>/dev/null; then + msg_ok "Updated $MODULE_NAME" + else + msg_warn "Failed to update $MODULE_NAME to latest version" + ((failed_modules++)) || true + fi + fi + else + msg_info "Installing $MODULE_NAME@$MODULE_REQ_VERSION" + # Retry with --force to overwrite corepack-provided shims (pnpm/yarn), which now + # ship with recent corepack and cause EEXIST on /usr/bin/ + if $STD npm install -g "${MODULE_NAME}@${MODULE_REQ_VERSION}" 2>/dev/null || + $STD npm install -g --force "${MODULE_NAME}@${MODULE_REQ_VERSION}" 2>/dev/null; then + msg_ok "Installed $MODULE_NAME" + else + msg_warn "Failed to install $MODULE_NAME@$MODULE_REQ_VERSION" + ((failed_modules++)) || true + fi + fi + done + if ((failed_modules > 0)); then + msg_warn "$failed_modules Node.js module(s) failed: $NODE_MODULE" + fi + fi + + # pnpm v10+ blocks dependency build scripts by default. Do NOT force + # `dangerouslyAllowAllBuilds` globally: pnpm implements that flag as an empty + # `neverBuiltDependencies`, which then clashes with any project that ships its + # own `onlyBuiltDependencies` (every create-t3-app based app, e.g. Split Pro) + # and aborts with ERR_PNPM_CONFIG_CONFLICT_BUILT_DEPENDENCIES. Instead relax the + # strict check so an unapproved build is a warning, not a fatal error; scripts + # that truly need every build script executed enable that themselves. + if command -v pnpm >/dev/null 2>&1; then + pnpm config set --global strictDepBuilds false >/dev/null 2>&1 || true + 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 </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 </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}" +} + +# ------------------------------------------------------------------------------ +# Resolves the path of the active PHP-FPM unix socket. +# +# Description: +# - Prefers the socket of ${PHP_VERSION} when that variable is set, which is +# the deterministic case directly after setup_php ran +# - Falls back to the highest version found below /run/php +# - Webserver-agnostic: works for nginx (fastcgi_pass) and Caddy (php_fastcgi) +# +# Notes: +# - Prints the path to stdout, diagnostics go to stderr, so the result can be +# captured via command substitution +# - Deliberately avoids `head -n1`: it closes the pipe early and with +# `set -o pipefail` (see catch_errors) SIGPIPE would abort the install +# - php-fpm has to be running, the socket only exists after the service start +# +# Usage: +# PHP_SOCK=$(get_php_fpm_socket) +# echo "fastcgi_pass unix:${PHP_SOCK};" +# ------------------------------------------------------------------------------ +get_php_fpm_socket() { + local sock + if [[ -n "${PHP_VERSION:-}" && -S "/run/php/php${PHP_VERSION}-fpm.sock" ]]; then + echo "/run/php/php${PHP_VERSION}-fpm.sock" + return 0 + fi + sock=$(find /run/php -maxdepth 1 -name "php*-fpm.sock" -type s 2>/dev/null | sort -V | tail -1) + if [[ -z "$sock" ]]; then + msg_error "No active PHP-FPM socket found under /run/php" + return 1 + fi + echo "$sock" +} +# ------------------------------------------------------------------------------ +# Enables an nginx site, validates the configuration and reloads the service. +# +# Description: +# - Removes the packaged default vhost (idempotent, unlike `unlink`) +# - Links the site from sites-available into sites-enabled +# - Runs `nginx -t` and aborts before a broken config reaches a running service +# - Enables the unit so nginx survives a reboot, then restarts it via +# safe_service_restart so a failed start is actually reported +# +# Variables: +# $1 - site name, has to exist as /etc/nginx/sites-available/ +# +# Usage: +# cat </etc/nginx/sites-available/myapp +# ... +# EOF +# nginx_enable_site myapp +# ------------------------------------------------------------------------------ +nginx_enable_site() { + local site="${1:-}" + if [[ -z "$site" ]]; then + msg_error "nginx_enable_site: no site name given" + return 1 + fi + if [[ ! -f "/etc/nginx/sites-available/${site}" ]]; then + msg_error "nginx site config not found: /etc/nginx/sites-available/${site}" + return 1 + fi + rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default + ln -sf "/etc/nginx/sites-available/${site}" "/etc/nginx/sites-enabled/${site}" + if ! $STD nginx -t; then + msg_error "nginx configuration test failed for site '${site}'" + return 1 + fi + $STD systemctl enable -q nginx + safe_service_restart nginx +} + +# ------------------------------------------------------------------------------ +# 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" + # Escape single quotes in identifiers to prevent SQL injection + local _pg_user_escaped _pg_pass_escaped _pg_db_escaped + _pg_user_escaped="${PG_DB_USER//\'/\'\'}" + _pg_pass_escaped="${PG_DB_PASS//\'/\'\'}" + _pg_db_escaped="${PG_DB_NAME//\'/\'\'}" + + $STD sudo -u postgres psql -c "CREATE ROLE $_pg_user_escaped WITH LOGIN PASSWORD '$_pg_pass_escaped';" + $STD sudo -u postgres psql -c "CREATE DATABASE $_pg_db_escaped WITH OWNER $_pg_user_escaped 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 BASH_PROFILE_FILE="$HOME/.bash_profile" + local BASHRC_FILE="$HOME/.bashrc" + local TMP_DIR=$(mktemp -d) + + # Ensure HOME exists: callers may pass a not-yet-created home (e.g. a service + # user that is created later in the install), so the profile writes below do + # not fail on a missing directory. + mkdir -p "$HOME" + + if ! grep -q 'rbenv init' "$PROFILE_FILE" 2>/dev/null; then + cat <<'EOF' >>"$PROFILE_FILE" +export PATH="$HOME/.rbenv/bin:$PATH" +eval "$(rbenv init -)" +EOF + fi + if ! grep -q '.rbenv/shims' "$PROFILE_FILE" 2>/dev/null; then + echo 'export PATH="$HOME/.rbenv/shims:$HOME/.rbenv/bin:$PATH"' >>"$PROFILE_FILE" + fi + if [[ -f "$BASH_PROFILE_FILE" ]] && ! grep -q '.rbenv/shims' "$BASH_PROFILE_FILE"; then + echo 'export PATH="$HOME/.rbenv/shims:$HOME/.rbenv/bin:$PATH"' >>"$BASH_PROFILE_FILE" + fi + if [[ -f "$BASHRC_FILE" ]] && ! grep -q '.rbenv/shims' "$BASHRC_FILE"; then + echo 'export PATH="$HOME/.rbenv/shims:$HOME/.rbenv/bin:$PATH"' >>"$BASHRC_FILE" + fi + + # 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 + _install_ruby_build_plugin() { + 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" + 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" + return 7 + fi + + tar -xzf "$TMP_DIR/ruby-build.tar.gz" -C "$TMP_DIR" || { + msg_error "Failed to extract ruby-build" + return 251 + } + + mkdir -p "$RBENV_DIR/plugins/ruby-build" + cp -r "$TMP_DIR/ruby-build-${RUBY_BUILD_RELEASE}/." "$RBENV_DIR/plugins/ruby-build/" + return 0 + } + + if [[ ! -d "$RBENV_DIR/plugins/ruby-build" ]]; then + _install_ruby_build_plugin || { + rm -rf "$TMP_DIR" + return 7 + } + 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 + if [[ ! -f "$RBENV_DIR/plugins/ruby-build/share/ruby-build/$RUBY_VERSION" ]]; then + msg_info "Updating ruby-build definitions" + _install_ruby_build_plugin || { + rm -rf "$TMP_DIR" + return 7 + } + msg_ok "Updated ruby-build definitions" + fi + $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 | 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) + + # trap for TMP Cleanup + trap "rm -rf '$TMP_DIR'" EXIT + + # 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 target version (pinned via UV_VERSION or latest release) + local TARGET_VERSION="${UV_VERSION:-}" + if [[ -z "$TARGET_VERSION" ]]; then + TARGET_VERSION=$(get_latest_github_release "astral-sh/uv") || { + msg_error "Could not fetch latest uv version from GitHub API" + return 7 + } + fi + + # 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 target version + if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" == "$TARGET_VERSION" ]]; then + cache_installed_version "uv" "$TARGET_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 + + # Install specific Python version if requested (even when uv is already up to date) + 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 + + return 0 + fi + + # Scenario 2: New install or upgrade + if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_VERSION" != "$TARGET_VERSION" ]]; then + msg_info "Upgrade uv from $INSTALLED_VERSION to $TARGET_VERSION" + else + msg_info "Setup uv $TARGET_VERSION" + fi + + local UV_URL="https://github.com/astral-sh/uv/releases/download/${TARGET_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" "$TARGET_VERSION" + msg_ok "Setup uv $TARGET_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 + } + trap 'rm -rf "$tmpdir" "${unpack_tmp:-}"; trap - RETURN' RETURN + + curl -fsSL -o "$tmpdir/$filename" "$url" || { + msg_error "Download failed: $url" + 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" + 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" + return 100 + } + } + + msg_ok "Successfully installed .deb package" + return 0 + fi + + if [[ -z "$directory" ]]; then + msg_error "Directory parameter is required for archive extraction" + return 65 + fi + + msg_info "Extracting archive to $directory" + + mkdir -p "$directory" + + if [[ "${CLEAN_INSTALL:-0}" == "1" ]]; then + find "${directory:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o -print0 | xargs -0 rm -rf -- + 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" + return 251 + } + elif [[ "$archive_type" == "tar" ]]; then + tar --no-same-owner -xf "$tmpdir/$filename" -C "$unpack_tmp" || { + msg_error "Failed to extract TAR archive" + 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" + shopt -u dotglob nullglob + return 252 + } + else + msg_error "Inner directory is empty: $inner_dir" + shopt -u dotglob nullglob + 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" + shopt -u dotglob nullglob + return 252 + } + else + msg_error "Unpacked archive is empty" + shopt -u dotglob nullglob + return 252 + fi + shopt -u dotglob nullglob + fi + + 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 <$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 gitlab_url="${GITLAB_URL:-https://gitlab.com}" + local http_code + http_code=$(curl --connect-timeout 10 --max-time 30 -sSL \ + -w "%{http_code}" -o "$temp_file" \ + "${header[@]}" \ + "${gitlab_url}/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 (~/.) +# - 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 +# - Supports GITLAB_URL for self-hosted GitLab (default: https://gitlab.com) +# - 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 tag_prefix="${5:-}" # optional tag prefix filter (e.g. web-v2) + local app_lc="${app,,}" + local current_file="$HOME/.${app_lc}" + + local gitlab_url="${GITLAB_URL:-https://gitlab.com}" + local gitlab_hostname="${gitlab_url#*://}" + gitlab_hostname="${gitlab_hostname%%/*}" + + msg_info "Checking for update: ${app}" + + # DNS check + if ! getent hosts "$gitlab_hostname" >/dev/null 2>&1; then + msg_error "Network error: cannot resolve $gitlab_hostname" + return 6 + fi + + ensure_dependencies jq + + local gl_check_json + gl_check_json=$(mktemp /tmp/tools-gl-check-XXXXXX) || return 73 + trap 'rm -f "$gl_check_json"; trap - RETURN' RETURN + + 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 "$gl_check_json" \ + "${header[@]}" \ + "${gitlab_url}/api/v4/projects/$repo_encoded/releases/$pinned_encoded" 2>/dev/null) || true + if [[ "$http_code" == "200" ]] && [[ -s "$gl_check_json" ]]; then + releases_json="[$(<"$gl_check_json")]" + fi + rm -f "$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 "$gl_check_json" \ + "${header[@]}" \ + "${gitlab_url}/api/v4/projects/$repo_encoded/releases?per_page=100&order_by=released_at&sort=desc" 2>/dev/null) || true + + if [[ "$http_code" == "200" ]] && [[ -s "$gl_check_json" ]]; then + releases_json=$(<"$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 "$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 "$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 "$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 ${gitlab_url}/api/v4/version" + rm -f "$gl_check_json" + return 7 + else + msg_error "Unable to fetch releases for ${app} (HTTP ${http_code})" + rm -f "$gl_check_json" + return 22 + fi + rm -f "$gl_check_json" + fi + + if [[ -n "$tag_prefix" ]]; then + mapfile -t raw_tags < <(jq -r --arg p "$tag_prefix" \ + '.[] | select(.tag_name | startswith($p)) | .tag_name' <<<"$releases_json") + else + mapfile -t raw_tags < <(jq -r '.[] | .tag_name' <<<"$releases_json") + fi + if ((${#raw_tags[@]} == 0)); then + if [[ -n "$tag_prefix" ]]; then + msg_error "No releases matching prefix '${tag_prefix}' found for ${app} on GitLab" + else + msg_error "No releases found for ${app} on GitLab" + fi + 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 /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 ${gitlab_url}/api/v4/version" + else + msg_error "Failed to fetch release metadata (HTTP $http_code)" + fi + return 1 + fi + + local json tag_name + json=$(<"$gl_rel_json") + + if [[ "$version" == "latest" ]]; then + if [[ -n "$tag_prefix" ]]; then + json=$(echo "$json" | jq --arg p "$tag_prefix" \ + '[.[] | select(.tag_name | startswith($p))][0] // empty') + else + json=$(echo "$json" | jq '.[0] // empty') + fi + if [[ -z "$json" || "$json" == "null" ]]; then + if [[ -n "$tag_prefix" ]]; then + msg_error "No release matching prefix '${tag_prefix}' found for $repo on GitLab" + else + msg_error "No releases found for $repo on GitLab" + fi + 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="${gitlab_url}/$repo/-/archive/$tag_name/${app_lc}-${version_safe}.tar.gz" + filename="${app_lc}-${version_safe}.tar.gz" + + _download_source_tarball "$direct_tarball_url" "$tmpdir/$filename" "${header[@]}" || { + msg_error "Download failed: $direct_tarball_url" + return 1 + } + + _deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 1 + + ### 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" "$gitlab_url" "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" + return 1 + fi + + filename="${url_match##*/}" + curl $download_timeout -fsSL "${header[@]}" -o "$tmpdir/$filename" "$url_match" || { + msg_error "Download failed: $url_match" + 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" + 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)" + 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" "$gitlab_url" "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" + return 1 + } + + filename="${asset_url##*/}" + curl $download_timeout -fsSL "${header[@]}" -o "$tmpdir/$filename" "$asset_url" || { + msg_error "Download failed: $asset_url" + return 1 + } + + local unpack_tmp + unpack_tmp=$(mktemp -d) + _deploy_unpacked_archive "$tmpdir/$filename" "$target" "$unpack_tmp" || return + + ### Singlefile Mode ### + elif [[ "$mode" == "singlefile" ]]; then + local pattern="${6%\"}" + pattern="${pattern#\"}" + [[ -z "$pattern" ]] && { + msg_error "Mode 'singlefile' requires 6th parameter (asset filename pattern)" + 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" "$gitlab_url" "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" + 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" + return 1 + } + + if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then + chmod +x "$target/$target_file" + fi + + else + msg_error "Unknown mode: $mode" + return 1 + fi + + echo "$version" >"$version_file" + msg_ok "Deployed: $app ($version)" +} + +# ------------------------------------------------------------------------------ +# 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) + + 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 +}