Files
ProxmoxVE/misc/tools.func
T

10294 lines
361 KiB
Bash
Raw Normal View History

#!/bin/bash
2025-05-15 15:18:41 +02:00
# ==============================================================================
# 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
}
2026-06-29 23:12:04 +02:00
# ------------------------------------------------------------------------------
# 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"
2026-06-29 23:12:04 +02:00
}
# ------------------------------------------------------------------------------
# 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
2026-01-09 21:19:47 +01:00
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
2026-01-09 21:19:47 +01:00
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}' | sed 's/^v//' | 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)
}
# ------------------------------------------------------------------------------
# Checks whether purging the given package glob(s) would also remove packages
# outside those globs (i.e. reverse dependents, such as an app depending on
# mongodb-org-server). Uses `apt-get -s` (simulate) with the stable apt-get
# output format, not `apt`, whose output is explicitly unstable for scripting.
# Returns 0 (safe to purge) or 1 (unsafe - prints the unexpected packages).
# Usage: _purge_is_safe 'mongodb*' ['other-glob*' ...]
# ------------------------------------------------------------------------------
_purge_is_safe() {
local -a globs=("$@")
local -a collateral=()
local pkg glob matched
local sim_out
sim_out=$(apt-get -s purge -y "${globs[@]}" 2>/dev/null) || return 0
while IFS= read -r pkg; do
[[ -z "$pkg" ]] && continue
matched=0
for glob in "${globs[@]}"; do
# shellcheck disable=SC2053
[[ "$pkg" == $glob ]] && {
matched=1
break
}
done
((matched)) || collateral+=("$pkg")
done < <(awk '/^(Remv|Purg) /{print $2}' <<<"$sim_out")
if ((${#collateral[@]} > 0)); then
msg_warn "Refusing purge of '${globs[*]}': would also remove ${collateral[*]}"
return 1
fi
return 0
}
# ------------------------------------------------------------------------------
# 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"
_purge_is_safe 'mariadb*' || return 1
$STD apt purge -y 'mariadb*' >/dev/null 2>&1 || true
cleanup_tool_keyrings "mariadb"
;;
mysql)
stop_all_services "mysql"
_purge_is_safe 'mysql*' || return 1
$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"
_purge_is_safe 'mongodb*' || return 1
$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)
_purge_is_safe nodejs npm || return 1
$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"
_purge_is_safe 'php*' || return 1
$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"
_purge_is_safe 'postgresql*' || return 1
$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)
_purge_is_safe 'temurin*' 'adoptium*' 'openjdk*' || return 1
$STD apt purge -y 'temurin*' 'adoptium*' 'openjdk*' >/dev/null 2>&1 || true
cleanup_tool_keyrings "adoptium"
;;
ruby)
cleanup_legacy_install "ruby"
_purge_is_safe 'ruby*' || return 1
$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"
_purge_is_safe 'clickhouse*' || return 1
$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 <<EOF >/etc/apt/sources.list.d/mongodb-org-${version}.sources
Types: deb
URIs: ${repo_url}
Suites: ${suite}/mongodb-org/${version}
Components: ${repo_component}
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/mongodb-server-${version}.gpg
EOF
return 0
;;
nodejs)
if [[ -z "$repo_url" || -z "$gpg_key_url" ]]; then
msg_error "Node.js repository requires repo_url and gpg_key_url"
return 65
fi
cleanup_old_repo_files "nodesource"
# NodeSource uses deb822 format with GPG from repo
local distro_codename
distro_codename=$(get_os_info codename)
# Download GPG key from NodeSource with retry logic
if ! download_gpg_key "$gpg_key_url" "/etc/apt/keyrings/nodesource.gpg" "dearmor"; then
msg_error "Failed to import NodeSource GPG key"
return 7
fi
cat <<EOF >/etc/apt/sources.list.d/nodesource.sources
Types: deb
URIs: $repo_url
Suites: nodistro
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/nodesource.gpg
EOF
return 0
;;
php)
if [[ -z "$gpg_key_url" ]]; then
msg_error "PHP repository requires gpg_key_url"
return 65
fi
cleanup_old_repo_files "php"
# Download and install keyring with retry logic
if ! curl_with_retry "$gpg_key_url" "/tmp/debsuryorg-archive-keyring.deb"; then
msg_error "Failed to download PHP keyring"
msg_error "Hint: Check connectivity to packages.sury.org (packages.sury.org/php)"
return 7
fi
# Don't use /dev/null redirection for dpkg as it may use background processes
dpkg -i /tmp/debsuryorg-archive-keyring.deb >>"$(get_active_logfile)" 2>&1 || {
msg_error "Failed to install PHP keyring"
rm -f /tmp/debsuryorg-archive-keyring.deb
return 100
}
rm -f /tmp/debsuryorg-archive-keyring.deb
# Setup repository
local distro_codename
distro_codename=$(get_os_info codename)
cat <<EOF >/etc/apt/sources.list.d/php.sources
Types: deb
URIs: https://packages.sury.org/php
Suites: $distro_codename
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /usr/share/keyrings/deb.sury.org-php.gpg
EOF
return 0
;;
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 <<EOF >/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 <<EOF >/etc/apt/sources.list.d/postgresql.sources
Types: deb
URIs: http://apt.postgresql.org/pub/repos/apt
Suites: $distro_codename-pgdg
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/postgresql.gpg
EOF
return 0
;;
*)
msg_error "Unknown tool repository: $tool_name"
return 65
;;
esac
return 0
}
# ------------------------------------------------------------------------------
# Unified package upgrade function (with apt update caching)
# ------------------------------------------------------------------------------
upgrade_package() {
local package="$1"
# Use same caching logic as ensure_dependencies
local apt_cache_file="/var/cache/apt-update-timestamp"
local current_time=$(date +%s)
local last_update=0
if [[ -f "$apt_cache_file" ]]; then
last_update=$(cat "$apt_cache_file" 2>/dev/null || echo 0)
fi
if ((current_time - last_update > 300)); then
$STD apt update || {
msg_warn "APT update failed in upgrade_package - continuing with cached packages"
}
echo "$current_time" >"$apt_cache_file"
fi
$STD apt install --only-upgrade -y "$package" || {
msg_warn "Failed to upgrade $package"
return 100
}
}
# ------------------------------------------------------------------------------
# Repository availability check with caching
# ------------------------------------------------------------------------------
# Note: Must use -gA (global) because tools.func is sourced inside update_os()
# function scope. Plain 'declare -A' would create a local variable that gets
# destroyed when update_os() returns, causing "unbound variable" errors later
# when setup_postgresql/verify_repo_available tries to access the cache key.
declare -gA _REPO_CACHE 2>/dev/null || declare -A _REPO_CACHE 2>/dev/null || true
verify_repo_available() {
local repo_url="$1"
local suite="$2"
local cache_key="${repo_url}|${suite}"
local cache_ttl=300 # 5 minutes
# Check cache first (avoid repeated HTTP requests)
if [[ -n "${_REPO_CACHE[$cache_key]:-}" ]]; then
local cached_time cached_result
cached_time=$(echo "${_REPO_CACHE[$cache_key]}" | cut -d'|' -f1)
cached_result=$(echo "${_REPO_CACHE[$cache_key]}" | cut -d'|' -f2)
if (($(date +%s) - cached_time < cache_ttl)); then
[[ "$cached_result" == "1" ]] && return 0 || return 1
fi
fi
# Perform actual check with short timeout
local result=1
if curl -fsSL --max-time 5 --connect-timeout 3 "${repo_url}/dists/${suite}/Release" &>/dev/null; then
result=0
fi
# Cache the result
_REPO_CACHE[$cache_key]="$(date +%s)|$result"
return $result
}
# ------------------------------------------------------------------------------
# Ensure dependencies are installed (with apt/apk update caching)
# Supports both Debian (apt/dpkg) and Alpine (apk) systems
# ------------------------------------------------------------------------------
ensure_dependencies() {
local deps=("$@")
local missing=()
# Detect Alpine Linux
if [[ -f /etc/alpine-release ]]; then
for dep in "${deps[@]}"; do
if command -v "$dep" &>/dev/null; then
continue
fi
if apk info -e "$dep" &>/dev/null; then
continue
fi
missing+=("$dep")
done
if [[ ${#missing[@]} -gt 0 ]]; then
$STD apk add --no-cache "${missing[@]}" || {
local failed=()
for pkg in "${missing[@]}"; do
if ! $STD apk add --no-cache "$pkg" 2>/dev/null; then
failed+=("$pkg")
fi
done
if [[ ${#failed[@]} -gt 0 ]]; then
msg_error "Failed to install dependencies: ${failed[*]}"
return 1
fi
}
fi
return 0
fi
# Debian/Ubuntu: Fast batch check using dpkg-query
local installed_pkgs
installed_pkgs=$(dpkg-query -W -f='${Package}\n' 2>/dev/null | sort -u)
for dep in "${deps[@]}"; do
# First check if command exists (for binaries like jq, curl)
if command -v "$dep" &>/dev/null; then
continue
fi
# Then check if package is installed
if echo "$installed_pkgs" | grep -qx "$dep"; then
continue
fi
missing+=("$dep")
done
if [[ ${#missing[@]} -gt 0 ]]; then
# Only run apt update if not done recently (within last 5 minutes)
local apt_cache_file="/var/cache/apt-update-timestamp"
local current_time
current_time=$(date +%s)
local last_update=0
if [[ -f "$apt_cache_file" ]]; then
last_update=$(cat "$apt_cache_file" 2>/dev/null || echo 0)
fi
if ((current_time - last_update > 300)); then
# Ensure orphaned sources are cleaned before updating
cleanup_orphaned_sources 2>/dev/null || true
if ! $STD apt update; then
ensure_apt_working || return 100
fi
echo "$current_time" >"$apt_cache_file"
fi
$STD apt install -y "${missing[@]}" || {
# Fallback: try installing one by one to identify problematic package
local failed=()
for pkg in "${missing[@]}"; do
if ! $STD apt install -y "$pkg" 2>/dev/null; then
failed+=("$pkg")
fi
done
if [[ ${#failed[@]} -gt 0 ]]; then
msg_error "Failed to install dependencies: ${failed[*]}"
return 100
fi
}
fi
}
# ------------------------------------------------------------------------------
# Smart version comparison
# ------------------------------------------------------------------------------
version_gt() {
test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"
}
# ------------------------------------------------------------------------------
# Get system architecture (normalized)
# ------------------------------------------------------------------------------
get_system_arch() {
local arch_type="${1:-dpkg}" # dpkg, uname, or both
local arch
case "$arch_type" in
dpkg)
arch=$(dpkg --print-architecture 2>/dev/null)
;;
uname)
arch=$(uname -m)
[[ "$arch" == "x86_64" ]] && arch="amd64"
[[ "$arch" == "aarch64" ]] && arch="arm64"
;;
both | *)
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
[[ "$arch" == "x86_64" ]] && arch="amd64"
[[ "$arch" == "aarch64" ]] && arch="arm64"
;;
esac
echo "$arch"
}
# ------------------------------------------------------------------------------
# Create temporary directory with automatic cleanup
# 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 <path> [<path> ...] / restore_backup
#
# Standardized data backup helpers for update_script(). They replace the
# hand-rolled "cp/mv to a sibling dir, update, copy back, rm" dance that is
# duplicated across the ct/*.sh update functions.
#
# create_backup <path> [<path> ...]
# - Copies each given file/directory into a persistent store at
# /opt/<NSAPP>.backup, mirroring its absolute path inside the store, and
# records it in a manifest so restore_backup needs no arguments.
# - Idempotent 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/tty
reply="${reply:-n}"
if [[ ! "${reply,,}" =~ ^(y|yes)$ ]]; then
return 1
fi
local token
while true; do
read -rp "${TAB}Enter your GitHub PAT: " token </dev/tty
# Trim leading/trailing whitespace
token="$(echo "$token" | xargs)"
if [[ -z "$token" ]]; then
msg_warn "Token cannot be empty. Please try again."
continue
fi
if [[ "$token" =~ [[:space:]] ]]; then
msg_warn "Token must not contain spaces. Please try again."
continue
fi
# Validate before accepting
export GITHUB_TOKEN="$token"
if validate_github_token "$token"; then
break
else
msg_warn "Please enter a valid token, or press Ctrl+C to abort."
unset GITHUB_TOKEN
fi
done
msg_ok "GitHub token has been set."
return 0
}
# ------------------------------------------------------------------------------
# GitHub API call with authentication and rate limit handling
# ------------------------------------------------------------------------------
github_api_call() {
local url="$1"
local output_file="${2:-/dev/stdout}"
local max_retries=3
local retry_delay=2
local header_args=()
[[ -n "${GITHUB_TOKEN:-}" ]] && header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
local attempt=1
while ((attempt <= max_retries)); do
local http_code
http_code=$(curl -sSL -w "%{http_code}" -o "$output_file" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${header_args[@]}" \
"$url" 2>/dev/null) || true
case "$http_code" in
200)
return 0
;;
401)
msg_error "GitHub API authentication failed (HTTP 401)."
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
msg_error "Your GITHUB_TOKEN appears to be invalid or expired."
else
msg_error "The repository may require authentication."
fi
if prompt_for_github_token; then
header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
continue
fi
return 22
;;
403)
# Rate limit - check if we can retry
if [[ $attempt -lt $max_retries ]]; then
msg_warn "GitHub API rate limit, waiting ${retry_delay}s... (attempt $attempt/$max_retries)"
sleep "$retry_delay"
retry_delay=$((retry_delay * 2))
((attempt++))
continue
fi
msg_error "GitHub API rate limit exceeded (HTTP 403)."
if prompt_for_github_token; then
header_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
retry_delay=2
attempt=1
continue
fi
msg_error "To increase the limit, export a GitHub token before running the script:"
msg_error " export GITHUB_TOKEN=\"ghp_your_token_here\""
return 22
;;
404)
msg_error "GitHub repository or release not found (HTTP 404): $url"
return 22
;;
000 | "")
if [[ $attempt -lt $max_retries ]]; then
sleep "$retry_delay"
((attempt++))
continue
fi
msg_error "GitHub API connection failed (no response)."
msg_error "Check your network/DNS: curl -sSL https://api.github.com/rate_limit"
return 22
;;
*)
if [[ $attempt -lt $max_retries ]]; then
sleep "$retry_delay"
((attempt++))
continue
fi
msg_error "GitHub API call failed (HTTP $http_code)."
return 22
;;
esac
((attempt++))
done
msg_error "GitHub API call failed after ${max_retries} attempts: ${url}"
return 22
}
# ------------------------------------------------------------------------------
# Codeberg API call with retry logic
# ------------------------------------------------------------------------------
codeberg_api_call() {
local url="$1"
local output_file="${2:-/dev/stdout}"
local max_retries=3
local retry_delay=2
for attempt in $(seq 1 $max_retries); do
local http_code
http_code=$(curl -sSL -w "%{http_code}" -o "$output_file" \
-H "Accept: application/json" \
"$url" 2>/dev/null) || true
case "$http_code" in
200)
return 0
;;
401)
msg_error "Codeberg API authentication failed (HTTP 401)."
return 22
;;
403)
# Rate limit - retry
if [[ $attempt -lt $max_retries ]]; then
msg_warn "Codeberg API rate limit, waiting ${retry_delay}s... (attempt $attempt/$max_retries)"
sleep "$retry_delay"
retry_delay=$((retry_delay * 2))
continue
fi
msg_error "Codeberg API rate limit exceeded (HTTP 403)."
return 22
;;
404)
msg_error "Codeberg repository or release not found (HTTP 404): $url"
return 22
;;
000 | "")
if [[ $attempt -lt $max_retries ]]; then
sleep "$retry_delay"
continue
fi
msg_error "Codeberg API connection failed (no response)."
msg_error "Check your network/DNS: curl -sSL https://codeberg.org"
return 22
;;
*)
if [[ $attempt -lt $max_retries ]]; then
sleep "$retry_delay"
continue
fi
msg_error "Codeberg API call failed (HTTP $http_code)."
return 22
;;
esac
done
msg_error "Codeberg API call failed after ${max_retries} attempts: ${url}"
return 22
}
should_upgrade() {
local current="$1"
local target="$2"
[[ -z "$current" ]] && return 0
version_gt "$target" "$current" && return 0
return 1
}
# ------------------------------------------------------------------------------
# Get OS information (cached for performance)
# ------------------------------------------------------------------------------
get_os_info() {
local field="${1:-all}" # id, codename, version, version_id, all
# Cache OS info to avoid repeated file reads
if [[ -z "${_OS_ID:-}" ]]; then
export _OS_ID=$(awk -F= '/^ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
export _OS_CODENAME=$(awk -F= '/^VERSION_CODENAME=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
export _OS_VERSION=$(awk -F= '/^VERSION_ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
export _OS_VERSION_FULL=$(awk -F= '/^VERSION=/{gsub(/"/,"",$2); print $2}' /etc/os-release)
fi
case "$field" in
id) echo "$_OS_ID" ;;
codename) echo "$_OS_CODENAME" ;;
version) echo "$_OS_VERSION" ;;
version_id) echo "$_OS_VERSION" ;;
version_full) echo "$_OS_VERSION_FULL" ;;
all) echo "ID=$_OS_ID CODENAME=$_OS_CODENAME VERSION=$_OS_VERSION" ;;
*) echo "$_OS_ID" ;;
esac
}
# ------------------------------------------------------------------------------
# Check if running on specific OS
# ------------------------------------------------------------------------------
is_debian() {
[[ "$(get_os_info id)" == "debian" ]]
}
is_ubuntu() {
[[ "$(get_os_info id)" == "ubuntu" ]]
}
# ------------------------------------------------------------------------------
# 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
}
2026-03-02 10:29:04 +01:00
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
2026-03-02 10:29:04 +01:00
}
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
}
2026-03-18 11:15:02 +01:00
# ------------------------------------------------------------------------------
# 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=""
2026-03-18 11:15:02 +01:00
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
2026-03-18 11:15:02 +01:00
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
2026-03-18 11:15:02 +01:00
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
2026-03-18 11:15:02 +01:00
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 <url> <dest> [curl args...]
#
# Downloads a source .tar.gz to <dest> 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 <tarball_path> <target> <workdir>
#
# 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 <tarball_path> into <workdir>, then copies the contents
# of that top-level directory into <target>.
#
# - Honors CLEAN_INSTALL=1 (wipes <target> 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 <workdir>: 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 <archive_path> <target> <workdir>
#
# 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 <workdir>, then copies its payload into <target>. If the archive contains
# a single top-level directory, that directory is stripped (its contents land
# directly in <target>); otherwise the archive contents are copied as-is.
#
# - Honors CLEAN_INSTALL=1 (wipes <target> 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 <workdir>: 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
# <target> 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 ~/.<app>
#
# Usage:
# fetch_and_deploy_gh_tag "guacd" "apache/guacamole-server"
# fetch_and_deploy_gh_tag "guacd" "apache/guacamole-server" "latest" "/opt/guacamole-server"
#
# Arguments:
# $1 - App name (used for version file ~/.<app>)
# $2 - GitHub repo (owner/repo)
# $3 - Tag version (default: "latest" → auto-detect via get_latest_gh_tag)
# $4 - Target directory (default: /opt/$app)
#
# Notes:
# - Supports CLEAN_INSTALL=1 to wipe target before extracting
# - For repos that only publish tags, not GitHub Releases
# ------------------------------------------------------------------------------
fetch_and_deploy_gh_tag() {
local app="$1"
local repo="$2"
local version="${3:-latest}"
local target="${4:-/opt/$app}"
local app_lc=""
app_lc="$(echo "${app,,}" | tr -d ' ')"
local version_file="$HOME/.${app_lc}"
if [[ "$version" == "latest" ]]; then
version=$(get_latest_gh_tag "$repo") || {
msg_error "Failed to determine latest tag for ${repo}"
return 250
}
fi
local current_version=""
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
if [[ "$current_version" == "$version" ]]; then
msg_ok "$app is already up-to-date ($version)"
return 0
fi
local tmpdir
tmpdir=$(mktemp -d) || return 1
2026-07-21 22:49:58 +02:00
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 ~/.<app> 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 ~/.<app> 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
2026-07-21 22:49:58 +02:00
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 (~/.<app>)
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
#
# Usage:
# if check_for_gh_tag "guacd" "apache/guacamole-server"; then
# fetch_and_deploy_gh_tag "guacd" "apache/guacamole-server" "/opt/guacamole-server"
# fi
#
# Notes:
# - For repos that only publish tags, not GitHub Releases
# - Same interface as check_for_gh_release
# ------------------------------------------------------------------------------
check_for_gh_tag() {
local app="$1"
local repo="$2"
local prefix="${3:-}"
local app_lc=""
app_lc="$(echo "${app,,}" | tr -d ' ')"
local current_file="$HOME/.${app_lc}"
msg_info "Checking for update: ${app}"
local latest=""
latest=$(get_latest_gh_tag "$repo" "$prefix") || return 22
local current=""
[[ -f "$current_file" ]] && current="$(<"$current_file")"
if [[ -z "$current" || "$current" != "$latest" ]]; then
CHECK_UPDATE_RELEASE="$latest"
msg_ok "Update available: ${app} ${current:-not installed} → ${latest}"
return 0
fi
msg_ok "No update available: ${app} (${latest})"
return 1
}
# ==============================================================================
# INSTALL FUNCTIONS
# ==============================================================================
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
2025-09-11 12:44:13 +02:00
# Checks for new GitHub release (latest tag).
2025-05-15 15:18:41 +02:00
#
# Description:
2025-09-11 12:44:13 +02:00
# - Queries the GitHub API for the latest release tag
# - Compares it to a local cached version (~/.<app>)
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
2025-05-15 15:18:41 +02:00
#
2025-09-11 12:44:13 +02:00
# 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
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
TOOLS_GH_REL_JSON=""
2025-09-11 12:44:13 +02:00
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 ' ')"
2025-09-11 12:44:13 +02:00
local current_file="$HOME/.${app_lc}"
2025-05-15 15:18:41 +02:00
2025-09-11 12:44:13 +02:00
msg_info "Checking for update: ${app}"
2025-09-11 12:44:13 +02:00
# 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
2025-04-28 15:25:10 +02:00
ensure_dependencies jq
local gh_check_json=""
2026-06-26 15:47:48 +10:00
gh_check_json=$(mktemp /tmp/tools-gh-check-XXXXXX) || return 73
2026-07-21 22:49:58 +02:00
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
2025-04-28 15:25:10 +02:00
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
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
fi
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
done
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
local latest_raw="${raw_tags[0]}"
local latest_clean="${clean_tags[0]}"
2025-09-11 12:44:13 +02:00
# 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
2025-09-11 12:44:13 +02:00
# 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
2025-09-11 12:44:13 +02:00
local match_raw=""
for i in "${!clean_tags[@]}"; do
if [[ "${clean_tags[$i]}" == "$pin_clean" ]]; then
match_raw="${raw_tags[$i]}"
break
fi
2025-09-11 12:44:13 +02:00
done
if [[ -z "$match_raw" ]]; then
msg_error "Pinned version ${pinned_version_in} not found upstream"
return 250
fi
2025-09-11 12:44:13 +02:00
if [[ "$current" != "$pin_clean" ]]; then
CHECK_UPDATE_RELEASE="$match_raw"
msg_ok "Update available: ${app} ${current:-not installed} → ${pin_clean}"
2025-09-11 12:44:13 +02:00
return 0
2025-04-28 15:25:10 +02:00
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
2025-09-11 12:44:13 +02:00
return 1
fi
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
# 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}"
2025-09-11 12:44:13 +02:00
return 0
2025-06-26 18:24:43 +02:00
fi
msg_ok "No update available: ${app} (${latest_clean})"
2025-09-11 12:44:13 +02:00
return 1
}
# ------------------------------------------------------------------------------
# Checks for new Codeberg release (latest tag).
#
# Description:
# - Queries the Codeberg API for the latest release tag
# - Compares it to a local cached version (~/.<app>)
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
#
# Usage:
# if check_for_codeberg_release "autocaliweb" "gelbphoenix/autocaliweb" [optional] "v0.11.3"; then
# # trigger update...
# fi
# exit 0
# } (end of update_script not from the function)
#
# Notes:
# - Requires `jq` (auto-installed if missing)
# - Does not modify anything, only checks version state
# - Does not support pre-releases
# ------------------------------------------------------------------------------
check_for_codeberg_release() {
local app="$1"
local source="$2"
local pinned_version_in="${3:-}" # optional
local pin_reason="${4:-}" # optional reason shown to user
local app_lc="${app,,}"
local current_file="$HOME/.${app_lc}"
msg_info "Checking for update: ${app}"
# DNS check
if ! getent hosts codeberg.org >/dev/null 2>&1; then
msg_error "Network error: cannot resolve codeberg.org"
return 6
fi
ensure_dependencies jq
# Fetch releases from Codeberg API
local releases_json=""
releases_json=$(curl -fsSL --max-time 20 \
-H 'Accept: application/json' \
"https://codeberg.org/api/v1/repos/${source}/releases" 2>/dev/null) || {
msg_error "Unable to fetch releases for ${app} (codeberg.org/api/v1/repos/${source}/releases)"
return 22
}
mapfile -t raw_tags < <(jq -r '.[] | select(.draft==false and .prerelease==false) | .tag_name' <<<"$releases_json")
if ((${#raw_tags[@]} == 0)); then
msg_error "No stable releases found for ${app}"
return 250
fi
local clean_tags=()
for t in "${raw_tags[@]}"; do
# Only strip leading 'v' when followed by a digit (e.g. v1.2.3)
if [[ "$t" =~ ^v[0-9] ]]; then
clean_tags+=("${t:1}")
else
clean_tags+=("$t")
fi
done
local latest_raw="${raw_tags[0]}"
local latest_clean="${clean_tags[0]}"
# current installed (stored without v)
local current=""
if [[ -f "$current_file" ]]; then
current="$(<"$current_file")"
else
# Migration: search for any /opt/*_version.txt
local legacy_files
mapfile -t legacy_files < <(find /opt -maxdepth 1 -type f -name "*_version.txt" 2>/dev/null)
if ((${#legacy_files[@]} == 1)); then
current="$(<"${legacy_files[0]}")"
echo "${current#v}" >"$current_file"
rm -f "${legacy_files[0]}"
fi
fi
current="${current#v}"
# Pinned version handling
if [[ -n "$pinned_version_in" ]]; then
local pin_clean="${pinned_version_in#v}"
local match_raw=""
for i in "${!clean_tags[@]}"; do
if [[ "${clean_tags[$i]}" == "$pin_clean" ]]; then
match_raw="${raw_tags[$i]}"
break
fi
done
if [[ -z "$match_raw" ]]; then
msg_error "Pinned version ${pinned_version_in} not found upstream"
return 250
fi
if [[ "$current" != "$pin_clean" ]]; then
CHECK_UPDATE_RELEASE="$match_raw"
msg_ok "Update available: ${app} ${current:-not installed} → ${pin_clean}"
return 0
fi
if [[ -n "$pin_reason" ]]; then
msg_ok "No update available: ${app} (${current}) - update held back: ${pin_reason}"
else
msg_ok "No update available: ${app} (${current}) - update temporarily held back due to issues with newer releases"
fi
return 1
fi
# No pinning → use latest
if [[ -z "$current" || "$current" != "$latest_clean" ]]; then
CHECK_UPDATE_RELEASE="$latest_raw"
msg_ok "Update available: ${app} ${current:-not installed} → ${latest_clean}"
return 0
fi
msg_ok "No update available: ${app} (${latest_clean})"
return 1
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
2025-09-11 12:44:13 +02:00
# Creates and installs self-signed certificates.
2025-05-15 15:18:41 +02:00
#
# Description:
2025-09-11 12:44:13 +02:00
# - Create a self-signed certificate with option to override application name
2025-05-15 15:18:41 +02:00
#
# Variables:
2025-09-11 12:44:13 +02:00
# APP - Application name (default: $APPLICATION variable)
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
create_self_signed_cert() {
local APP_NAME="${1:-${APPLICATION}}"
2026-03-17 21:19:36 +01:00
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 \
2026-03-17 21:19:36 +01:00
-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"
2025-09-11 12:44:13 +02:00
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# Downloads file with optional progress indicator using pv.
#
# Arguments:
# $1 - URL
# $2 - Destination path
# ------------------------------------------------------------------------------
download_with_progress() {
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
set -o pipefail
2025-09-11 12:44:13 +02:00
# Content-Length aus HTTP-Header holen
local content_length
content_length=$(curl -fsSLI "$url" | awk '/Content-Length/ {print $2}' | tr -d '\r' || true)
2025-09-11 12:44:13 +02:00
if [[ -z "$content_length" ]]; then
if ! curl -fL# -o "$output" "$url"; then
msg_error "Download failed: $url"
return 7
2025-05-15 15:18:41 +02:00
fi
2025-09-11 12:44:13 +02:00
else
if ! curl -fsSL "$url" | pv -s "$content_length" >"$output"; then
msg_error "Download failed: $url"
return 7
fi
2025-09-11 12:44:13 +02:00
fi
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# 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)
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
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
2025-09-11 12:44:13 +02:00
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
2025-04-28 15:25:10 +02:00
}
# ------------------------------------------------------------------------------
# curl_download - Downloads a file with stall detection and retry.
#
# Usage: curl_download <output_file> <url>
#
# Uses --speed-limit / --speed-time instead of a hard --max-time cap so that
# slow but progressing downloads (e.g. large .deb files from slow mirrors) are
# never aborted mid-transfer. Only aborts when throughput drops below 1 KB/s
# for 60 consecutive seconds (i.e. a genuine stall or dead connection).
# Retries up to 3 times on failure.
# Returns 0 on success, 7 if all attempts fail.
# ------------------------------------------------------------------------------
curl_download() {
local output="$1"
local url="$2"
local retries=3
local attempt=1
while ((attempt <= retries)); do
if curl --connect-timeout 15 \
--speed-limit 1024 \
--speed-time 60 \
-fsSL -o "$output" "$url"; then
return 0
fi
if ((attempt < retries)); then
msg_warn "Download failed or stalled (attempt ${attempt}/${retries}), retrying..."
fi
((attempt++))
done
return 7
}
# ------------------------------------------------------------------------------
# Downloads and deploys latest Codeberg release (source, binary, tarball, asset).
#
# Description:
# - Fetches latest release metadata from Codeberg API
# - Supports the following modes:
# - tarball: Source code tarball (default if omitted)
# - source: Alias for tarball (same behavior)
# - binary: .deb package install (arch-dependent)
# - prebuild: Prebuilt .tar.gz archive (e.g. Go binaries)
# - singlefile: Standalone binary (no archive, direct chmod +x install)
# - tag: Direct tag download (bypasses Release API)
# - Handles download, extraction/installation and version tracking in ~/.<app>
#
# Parameters:
# $1 APP - Application name (used for install path and version file)
# $2 REPO - Codeberg repository in form user/repo
# $3 MODE - Release type:
# tarball → source tarball (.tar.gz)
# binary → .deb file (auto-arch matched)
# prebuild → prebuilt archive (e.g. tar.gz)
# singlefile→ standalone binary (chmod +x)
# tag → direct tag (bypasses Release API)
# $4 VERSION - Optional release tag (default: latest)
# $5 TARGET_DIR - Optional install path (default: /opt/<app>)
# $6 ASSET_FILENAME - Required for:
# - prebuild → archive filename or pattern
# - singlefile→ binary filename or pattern
#
# Examples:
# # 1. Minimal: Fetch and deploy source tarball
# fetch_and_deploy_codeberg_release "autocaliweb" "gelbphoenix/autocaliweb"
#
# # 2. Binary install via .deb asset (architecture auto-detected)
# fetch_and_deploy_codeberg_release "myapp" "myuser/myapp" "binary"
#
# # 3. Prebuilt archive (.tar.gz) with asset filename match
# fetch_and_deploy_codeberg_release "myapp" "myuser/myapp" "prebuild" "latest" "/opt/myapp" "myapp_Linux_x86_64.tar.gz"
#
# # 4. Single binary (chmod +x)
# fetch_and_deploy_codeberg_release "myapp" "myuser/myapp" "singlefile" "v1.0.0" "/opt/myapp" "myapp-linux-amd64"
#
# # 5. Explicit tag version
# fetch_and_deploy_codeberg_release "autocaliweb" "gelbphoenix/autocaliweb" "tag" "v0.11.3" "/opt/autocaliweb"
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# _diagnose_deb_failure()
#
# - Called when both apt and dpkg fail to install a .deb package
# - Extracts package metadata and detects common failure patterns
# - Outputs enhanced error messages with actionable hints:
# * PostgreSQL version conflicts (e.g., postgresql-16-foo with pg17 active)
# * Missing declared dependencies
# * Generic fallback hint pointing to the log
#
# Usage: _diagnose_deb_failure "/path/to/file.deb"
# Returns: always 0 (diagnostic only — caller must return the error code)
# ------------------------------------------------------------------------------
_diagnose_deb_failure() {
local deb_path="$1"
local filename="${deb_path##*/}"
local pkg_name pkg_deps pkg_version
pkg_name=$(dpkg-deb -f "$deb_path" Package 2>/dev/null || echo "${filename%%_*}")
pkg_version=$(dpkg-deb -f "$deb_path" Version 2>/dev/null || true)
pkg_deps=$(dpkg-deb -f "$deb_path" Depends 2>/dev/null || true)
msg_error "Failed to install '${pkg_name}${pkg_version:+ (${pkg_version})}' — both apt and dpkg reported errors"
# Detect PostgreSQL version conflict (e.g., postgresql-16-vchord while pg17 is active)
local pg_ver_needed pg_ver_installed
pg_ver_needed=$(echo "$filename" | grep -oP '(?<=postgresql-)[0-9]+(?=-)' | head -1 || true)
if [[ -n "$pg_ver_needed" ]]; then
pg_ver_installed=$(psql -V 2>/dev/null | awk '{print $3}' | cut -d. -f1 || true)
if [[ -n "$pg_ver_installed" && "$pg_ver_needed" != "$pg_ver_installed" ]]; then
msg_error "Version conflict: '${pkg_name}' is built for PostgreSQL ${pg_ver_needed}, but PostgreSQL ${pg_ver_installed} is installed on this system."
msg_error "Hint: Your distribution installed a different PostgreSQL version than expected. The script may need updating to use postgresql-${pg_ver_installed}-* packages."
return 0
fi
fi
# Show which declared dependencies are not satisfied
if [[ -n "$pkg_deps" ]]; then
local missing_deps=()
while IFS=',' read -ra dep_list; do
for dep_entry in "${dep_list[@]}"; do
local dep_pkg
dep_pkg=$(echo "$dep_entry" | awk '{print $1}' | tr -d ' ')
[[ -z "$dep_pkg" || "$dep_pkg" == "("* ]] && continue
if ! dpkg-query -W -f='${Status}' "$dep_pkg" 2>/dev/null | grep -q "install ok installed"; then
missing_deps+=("$dep_pkg")
fi
done
done <<<"$pkg_deps"
if [[ ${#missing_deps[@]} -gt 0 ]]; then
msg_error "Unmet dependencies: ${missing_deps[*]}"
msg_error "Hint: Run 'apt-get install -f' inside the container to attempt automatic dependency resolution."
else
msg_error "Hint: Declared dependencies appear present but installation still failed. Check the log above for the exact error."
fi
else
msg_error "Hint: Check the installation log above for the exact dependency or configuration error."
fi
}
fetch_and_deploy_codeberg_release() {
local app="$1"
local repo="$2"
local mode="${3:-tarball}" # tarball | binary | prebuild | singlefile | tag
2026-02-14 16:37:13 +01:00
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
2026-07-21 22:49:58 +02:00
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
2026-06-26 15:47:48 +10:00
codeberg_rel_json=$(mktemp /tmp/tools-codeberg-rel-XXXXXX) || return 73
2026-07-21 22:49:58 +02:00
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)"
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
2025-09-11 12:44:13 +02:00
# Downloads and deploys latest GitHub release (source, binary, tarball, asset).
2025-05-15 15:18:41 +02:00
#
# Description:
2025-09-11 12:44:13 +02:00
# - Fetches latest release metadata from GitHub API
# - Supports the following modes:
# - tarball: Source code tarball (default if omitted)
# - source: Alias for tarball (same behavior)
# - binary: .deb package install (arch-dependent)
# - prebuild: Prebuilt .tar.gz archive (e.g. Go binaries)
# - singlefile: Standalone binary (no archive, direct chmod +x install)
# - Handles download, extraction/installation and version tracking in ~/.<app>
2025-05-15 15:18:41 +02:00
#
2025-09-11 12:44:13 +02:00
# Parameters:
# $1 APP - Application name (used for install path and version file)
# $2 REPO - GitHub repository in form user/repo
# $3 MODE - Release type:
# tarball → source tarball (.tar.gz)
# binary → .deb file (auto-arch matched)
# prebuild → prebuilt archive (e.g. tar.gz)
# singlefile→ standalone binary (chmod +x)
# $4 VERSION - Optional release tag (default: latest)
# $5 TARGET_DIR - Optional install path (default: /opt/<app>)
# $6 ASSET_FILENAME - Required for:
# - prebuild → archive filename or pattern
# - singlefile→ binary filename or pattern
#
# Optional:
# - Set GITHUB_TOKEN env var to increase API rate limit (recommended for CI/CD).
#
# Examples:
# # 1. Minimal: Fetch and deploy source tarball
# fetch_and_deploy_gh_release "myapp" "myuser/myapp"
#
# # 2. Binary install via .deb asset (architecture auto-detected)
# fetch_and_deploy_gh_release "myapp" "myuser/myapp" "binary"
#
# # 3. Prebuilt archive (.tar.gz) with asset filename match
# fetch_and_deploy_gh_release "hanko" "teamhanko/hanko" "prebuild" "latest" "/opt/hanko" "hanko_Linux_x86_64.tar.gz"
#
# # 4. Single binary (chmod +x) like Argus, Promtail etc.
# fetch_and_deploy_gh_release "argus" "release-argus/Argus" "singlefile" "0.26.3" "/opt/argus" "Argus-.*linux-amd64"
#
# Notes:
# - For binary/prebuild/singlefile modes: if the target release has no
# matching asset, the function scans older releases and prompts the user
# (60s timeout, default yes) to use a previous version that has the asset.
# ------------------------------------------------------------------------------
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
# 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/tty || use_fallback="y"
use_fallback="${use_fallback:-y}"
fi
if [[ "${use_fallback,,}" == "y" || "${use_fallback,,}" == "yes" ]]; then
echo "$releases_list" | jq ".[$i]"
return 0
else
return 250
fi
fi
done
return 250
}
2025-05-15 15:18:41 +02:00
fetch_and_deploy_gh_release() {
2025-09-11 12:44:13 +02:00
local app="$1"
local repo="$2"
local mode="${3:-tarball}" # tarball | binary | prebuild | singlefile
2026-02-14 16:37:13 +01:00
local version="${var_appversion:-${4:-latest}}"
2025-09-11 12:44:13 +02:00
local target="${5:-/opt/$app}"
local asset_pattern="${6:-}"
local tag_prefix="${7:-}"
# Validate app name to prevent /root/. directory issues
if [[ -z "$app" ]]; then
# Derive app name from repo if not provided
app="${repo##*/}"
if [[ -z "$app" ]]; then
msg_error "fetch_and_deploy_gh_release requires app name or valid repo"
return 65
fi
fi
2025-09-11 12:44:13 +02:00
local app_lc=$(echo "${app,,}" | tr -d ' ')
local version_file="$HOME/.${app_lc}"
local api_timeouts=(60 120 240)
2025-09-11 12:44:13 +02:00
local current_version=""
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
ensure_dependencies jq
if [[ -n "${TOOLS_GH_REL_JSON:-}" && -f "$TOOLS_GH_REL_JSON" ]]; then
rm -f "$TOOLS_GH_REL_JSON"
fi
local gh_rel_json
2026-06-26 15:47:48 +10:00
gh_rel_json=$(mktemp /tmp/tools-gh-rel-XXXXXX) || return 73
TOOLS_GH_REL_JSON="$gh_rel_json"
2025-09-11 12:44:13 +02:00
local api_url="https://api.github.com/repos/$repo/releases"
if [[ "$version" != "latest" ]]; then
api_url="$api_url/tags/$version"
elif [[ -n "$tag_prefix" ]]; then
api_url="$api_url?per_page=100"
else
api_url="$api_url/latest"
fi
2025-09-11 12:44:13 +02:00
local header=()
[[ -n "${GITHUB_TOKEN:-}" ]] && header=(-H "Authorization: token $GITHUB_TOKEN")
2025-09-11 12:44:13 +02:00
# dns pre check
local gh_host
gh_host=$(awk -F/ '{print $3}' <<<"$api_url")
if ! getent hosts "$gh_host" &>/dev/null; then
msg_error "DNS resolution failed for $gh_host – check /etc/resolv.conf or networking"
return 6
2025-09-11 12:44:13 +02:00
fi
local max_retries=${#api_timeouts[@]} retry_delay=2 attempt=1 success=false http_code
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
((attempt++))
done
2025-08-08 10:28:05 +02:00
2025-09-11 12:44:13 +02:00
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
2025-08-08 10:28:05 +02:00
fi
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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//\//-}"
2025-09-11 12:44:13 +02:00
if [[ "$current_version" == "$version" ]]; then
$STD msg_ok "$app is already up-to-date (v$version)"
return 0
fi
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
local tmpdir
tmpdir=$(mktemp -d) || return 1
2026-07-21 22:49:58 +02:00
trap 'rm -rf "$tmpdir" "${unpack_tmp:-}"; trap - RETURN' RETURN
2025-09-11 12:44:13 +02:00
local filename="" url=""
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
msg_info "Fetching GitHub release: $app ($version)"
2025-04-28 15:25:10 +02:00
local clean_install=false
[[ -n "${CLEAN_INSTALL:-}" && "$CLEAN_INSTALL" == "1" ]] && clean_install=true
2025-09-11 12:44:13 +02:00
### 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"
2025-04-28 15:25:10 +02:00
_download_source_tarball "$direct_tarball_url" "$tmpdir/$filename" || {
msg_error "Download failed: $direct_tarball_url"
return 250
2025-09-11 12:44:13 +02:00
}
2025-05-15 15:18:41 +02:00
_deploy_source_tarball "$tmpdir/$filename" "$target" "$tmpdir" || return 251
2025-09-11 12:44:13 +02:00
### 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"
2025-09-11 12:44:13 +02:00
local assets url_match=""
assets=$(echo "$json" | jq -r '.assets[].browser_download_url')
2025-09-11 12:44:13 +02:00
# 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
2025-09-11 12:44:13 +02:00
# 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
2025-09-11 12:44:13 +02:00
fi
url_match="$u"
break
2025-09-11 12:44:13 +02:00
done
fi
2025-09-11 12:44:13 +02:00
# 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
2025-09-11 12:44:13 +02:00
if [[ -z "$url_match" ]]; then
msg_error "No suitable .deb asset found for $app"
return 252
2025-09-11 12:44:13 +02:00
fi
2025-09-11 12:44:13 +02:00
filename="${url_match##*/}"
curl_download "$tmpdir/$filename" "$url_match" || {
2025-09-11 12:44:13 +02:00
msg_error "Download failed: $url_match"
return 250
2025-09-11 12:44:13 +02:00
}
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
2025-09-11 12:44:13 +02:00
}
}
### Prebuild Mode ###
2025-09-11 12:44:13 +02:00
elif [[ "$mode" == "prebuild" ]]; then
local pattern="${6%\"}"
pattern="${pattern#\"}"
[[ -z "$pattern" ]] && {
msg_error "Mode 'prebuild' requires 6th parameter (asset filename pattern)"
return 65
2025-09-11 12:44:13 +02:00
}
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
2025-09-11 12:44:13 +02:00
[[ -z "$asset_url" ]] && {
msg_error "No asset matching '$pattern' found"
return 252
2025-09-11 12:44:13 +02:00
}
filename="${asset_url##*/}"
curl_download "$tmpdir/$filename" "$asset_url" || {
2025-09-11 12:44:13 +02:00
msg_error "Download failed: $asset_url"
return 250
2025-09-11 12:44:13 +02:00
}
local unpack_tmp
unpack_tmp=$(mktemp -d)
_deploy_unpacked_archive "$tmpdir/$filename" "$target" "$unpack_tmp" || return
2025-09-11 12:44:13 +02:00
### 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
2025-09-11 12:44:13 +02:00
}
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
2025-09-11 12:44:13 +02:00
[[ -z "$asset_url" ]] && {
msg_error "No asset matching '$pattern' found"
return 252
2025-09-11 12:44:13 +02:00
}
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" || {
2025-09-11 12:44:13 +02:00
msg_error "Download failed: $asset_url"
return 250
2025-09-11 12:44:13 +02:00
}
if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then
chmod +x "$target/$target_file"
fi
else
msg_error "Unknown mode: $mode"
return 65
fi
2025-09-11 12:44:13 +02:00
echo "$version" >"$version_file"
msg_ok "Deployed: $app ($version)"
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# 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() {
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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)"
2025-09-11 12:44:13 +02:00
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
2025-04-28 15:25:10 +02:00
}
2025-08-04 14:12:18 +02:00
# ------------------------------------------------------------------------------
# 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" || {
msg_error "Aborting ClickHouse upgrade: another package depends on it"
return 1
}
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"
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
2025-08-04 14:12:18 +02:00
# Installs or updates Composer globally (robust, idempotent).
2025-05-15 15:18:41 +02:00
#
2025-08-04 14:12:18 +02:00
# - 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
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
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"
2025-08-04 14:12:18 +02:00
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
2025-08-04 14:12:18 +02:00
if [[ ! -x "$COMPOSER_BIN" ]]; then
msg_error "Composer installation failed"
return 127
fi
2025-04-28 15:25:10 +02:00
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"
2025-04-28 15:25:10 +02:00
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# Docker Engine Installation and Management (All-In-One)
2025-09-11 12:44:13 +02:00
#
# 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
2025-09-11 12:44:13 +02:00
#
# 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)
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
_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}"
2025-09-11 12:44:13 +02:00
# 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 <<EOF >/etc/docker/daemon.json
{
"log-driver": "$log_driver"
}
EOF
fi
# Enable and start Docker
systemctl enable -q --now docker
# Continue to Portainer section below
else
# Scenario 2: Use official Docker repository (USE_DOCKER_REPO=true)
# Cleanup old repository configurations
if [ -f /etc/apt/sources.list.d/docker.list ]; then
msg_info "Migrating from old Docker repository format"
rm -f /etc/apt/sources.list.d/docker.list
rm -f /etc/apt/keyrings/docker.asc
fi
# Setup/Update Docker repository
msg_info "Setting up Docker Repository"
setup_deb822_repo \
"docker" \
"https://download.docker.com/linux/$(get_os_info id)/gpg" \
"https://download.docker.com/linux/$(get_os_info id)" \
"$(get_os_info codename)" \
"stable" \
"$(dpkg --print-architecture)"
# Install or upgrade Docker
if [ "$docker_installed" = true ]; then
msg_info "Checking for Docker updates"
DOCKER_LATEST_VERSION=$(apt-cache policy docker-ce | grep Candidate | awk '{print $2}' 2>/dev/null | cut -d':' -f2 | cut -d'-' -f1 || echo '')
if [ "$DOCKER_CURRENT_VERSION" != "$DOCKER_LATEST_VERSION" ]; then
msg_info "Updating Docker $DOCKER_CURRENT_VERSION → $DOCKER_LATEST_VERSION"
$STD apt install -y --only-upgrade \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin || {
msg_error "Failed to update Docker packages"
return 100
}
msg_ok "Updated Docker to $DOCKER_LATEST_VERSION"
else
msg_ok "Docker is up-to-date ($DOCKER_CURRENT_VERSION)"
fi
else
msg_info "Installing Docker"
$STD apt install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin || {
msg_error "Failed to install Docker packages"
return 100
}
DOCKER_CURRENT_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1)
msg_ok "Installed Docker $DOCKER_CURRENT_VERSION"
fi
# Configure daemon.json
local log_driver="${DOCKER_LOG_DRIVER:-journald}"
mkdir -p /etc/docker
if [ ! -f /etc/docker/daemon.json ]; then
cat <<EOF >/etc/docker/daemon.json
{
"log-driver": "$log_driver"
}
EOF
fi
# Enable and start Docker
systemctl enable -q --now docker
fi
# 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
2025-09-11 12:44:13 +02:00
if [[ -z "$VERSION" ]]; then
msg_info "Could not determine FFmpeg source version, using pre-built binary"
VERSION="" # Will use binary fallback
2025-09-11 12:44:13 +02:00
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
2026-04-24 14:29:55 +02:00
libdav1d-dev zlib1g-dev libnuma-dev
2025-09-11 12:44:13 +02:00
libva-dev libdrm-dev
)
2026-04-24 14:21:16 +02:00
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
2025-09-11 12:44:13 +02:00
;;
*)
msg_error "Invalid FFMPEG_TYPE: $TYPE"
rm -rf "$TMP_DIR"
return 65
2025-09-11 12:44:13 +02:00
;;
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
}
2025-09-11 12:44:13 +02:00
cd "$TMP_DIR/FFmpeg-"* || {
msg_error "Source extraction failed"
rm -rf "$TMP_DIR"
return 251
2025-09-11 12:44:13 +02:00
}
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"
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
return 65
2025-09-11 12:44:13 +02:00
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"
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
return 150
2025-09-11 12:44:13 +02:00
}
echo "/usr/local/lib" >/etc/ld.so.conf.d/ffmpeg.conf
$STD ldconfig
2025-09-11 12:44:13 +02:00
ldconfig -p 2>/dev/null | grep libavdevice >/dev/null || {
2025-09-11 12:44:13 +02:00
msg_error "libavdevice not registered with dynamic linker"
rm -rf "$TMP_DIR"
return 150
2025-09-11 12:44:13 +02:00
}
if ! command -v ffmpeg &>/dev/null; then
msg_error "FFmpeg installation failed"
rm -rf "$TMP_DIR"
return 150
2025-09-11 12:44:13 +02:00
fi
local FINAL_VERSION
FINAL_VERSION=$(ffmpeg -version 2>/dev/null | head -n1 | awk '{print $3}')
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
cache_installed_version "ffmpeg" "$FINAL_VERSION"
2025-09-11 12:44:13 +02:00
ensure_usr_local_bin_persist
[[ -n "$INSTALLED_VERSION" ]] && msg_ok "Upgrade FFmpeg $INSTALLED_VERSION → $FINAL_VERSION" || msg_ok "Setup FFmpeg $FINAL_VERSION"
2025-09-11 12:44:13 +02:00
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
# 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
2025-04-28 15:25:10 +02:00
# 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" || {
msg_error "Aborting Go upgrade: another package depends on it"
return 1
}
else
msg_info "Setup Go $GO_VERSION"
fi
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
$STD tar -C /usr/local -xzf "$TMP_TAR" || {
msg_error "Failed to extract Go tarball"
rm -f "$TMP_TAR"
return 251
}
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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/')
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
return 0
2025-09-11 12:44:13 +02:00
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"
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
return 0
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
if ! tar -xzf "$TMP_DIR/ghostscript.tar.gz" -C "$TMP_DIR"; then
msg_error "Failed to extract Ghostscript archive"
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
return 251
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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"
2025-09-11 12:44:13 +02:00
rm -rf "$TMP_DIR"
return 150
2025-09-11 12:44:13 +02:00
}
2025-09-11 12:44:13 +02:00
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"
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# 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
2026-01-06 20:15:22 +01:00
# 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 </dev/tty; then
selection="${selection^^}" # uppercase
else
echo ""
msg_info "Timeout - configuring all GPUs automatically"
selection="A"
fi
# Parse selection
if [[ "$selection" == "A" || -z "$selection" ]]; then
# Select all
for i in "${!GPU_LIST[@]}"; do
SELECTED_INDICES+=("$i")
done
elif [[ "$selection" =~ ^[0-9,]+$ ]]; then
# Parse comma-separated numbers
IFS=',' read -ra nums <<<"$selection"
for num in "${nums[@]}"; do
num=$(echo "$num" | tr -d ' ')
if [[ "$num" =~ ^[0-9]+$ ]] && ((num >= 1 && num <= gpu_count)); then
SELECTED_INDICES+=("$((num - 1))")
fi
done
else
# Invalid - default to all
msg_warn "Invalid selection - configuring all GPUs"
for i in "${!GPU_LIST[@]}"; do
SELECTED_INDICES+=("$i")
done
fi
fi
# Ask whether to install NVIDIA drivers in the container
local nvidia_selected="no"
for idx in "${SELECTED_INDICES[@]}"; do
if [[ "${GPU_TYPES[$idx]}" == "NVIDIA" ]]; then
nvidia_selected="yes"
break
fi
done
if [[ "$nvidia_selected" == "yes" ]]; then
if [[ -n "${INSTALL_NVIDIA_DRIVERS:-}" ]]; then
install_nvidia_drivers="${INSTALL_NVIDIA_DRIVERS}"
else
echo ""
msg_custom "🎮" "${GN}" "NVIDIA GPU passthrough detected"
local nvidia_reply=""
read -r -t 60 -p "${TAB3}⚙️ Install NVIDIA driver libraries in the container? [Y/n] (auto-yes in 60s): " nvidia_reply </dev/tty || nvidia_reply=""
case "${nvidia_reply,,}" in
n | no) install_nvidia_drivers="no" ;;
*) install_nvidia_drivers="yes" ;;
esac
fi
fi
# ═══════════════════════════════════════════════════════════════════════════
# OS Detection
# ═══════════════════════════════════════════════════════════════════════════
local os_id os_codename os_version
os_id=$(get_os_info id)
os_codename=$(get_os_info codename)
os_version=$(get_os_info version)
[[ -z "$os_id" ]] && os_id="debian"
local in_ct="${CTTYPE:-0}"
# ═══════════════════════════════════════════════════════════════════════════
# Process Selected GPUs
# ═══════════════════════════════════════════════════════════════════════════
for idx in "${SELECTED_INDICES[@]}"; do
local gpu_type="${GPU_TYPES[$idx]}"
local gpu_name="${GPU_NAMES[$idx]}"
msg_info "Configuring: ${gpu_name}"
case "$gpu_type" in
# ─────────────────────────────────────────────────────────────────────────
# Intel Arc GPUs (DG1, DG2, Arc A-series)
# ─────────────────────────────────────────────────────────────────────────
INTEL_ARC)
_setup_intel_arc "$os_id" "$os_codename"
;;
# ─────────────────────────────────────────────────────────────────────────
# Intel Gen 9+ (Skylake 2015+: UHD, Iris, HD 6xx+)
# ─────────────────────────────────────────────────────────────────────────
INTEL_GEN9+ | INTEL)
_setup_intel_modern "$os_id" "$os_codename"
;;
# ─────────────────────────────────────────────────────────────────────────
# Intel Legacy (Gen 6-8: HD 2000-5999, Sandy Bridge to Broadwell)
# ─────────────────────────────────────────────────────────────────────────
INTEL_LEGACY)
_setup_intel_legacy "$os_id" "$os_codename"
;;
# ─────────────────────────────────────────────────────────────────────────
# AMD Discrete GPUs
# ─────────────────────────────────────────────────────────────────────────
AMD)
_setup_amd_gpu "$os_id" "$os_codename"
;;
# ─────────────────────────────────────────────────────────────────────────
# AMD APU (Integrated Graphics)
# ─────────────────────────────────────────────────────────────────────────
AMD_APU)
_setup_amd_apu "$os_id" "$os_codename"
;;
# ─────────────────────────────────────────────────────────────────────────
# NVIDIA GPUs
# ─────────────────────────────────────────────────────────────────────────
NVIDIA)
if [[ "$install_nvidia_drivers" == "yes" ]]; then
_setup_nvidia_gpu "$os_id" "$os_codename" "$os_version"
else
msg_warn "Skipping NVIDIA driver installation (user opted to install manually)"
fi
;;
esac
done
# ═══════════════════════════════════════════════════════════════════════════
# Device Permissions
# ═══════════════════════════════════════════════════════════════════════════
_setup_gpu_permissions "$in_ct" "$service_user"
cache_installed_version "hwaccel" "1.0"
msg_ok "Setup Hardware Acceleration"
}
# ══════════════════════════════════════════════════════════════════════════════
# Resolve the IGC tag that the latest compute-runtime was built against.
# Must be called AFTER a fetch_and_deploy_gh_release for intel/compute-runtime
# so that TOOLS_GH_REL_JSON contains the compute-runtime release metadata.
# Sets the variable named by $1 (default: igc_tag) to the discovered tag.
# ══════════════════════════════════════════════════════════════════════════════
_resolve_igc_tag() {
local -n _out_ref="${1:-igc_tag}"
_out_ref="latest"
if [[ -n "${TOOLS_GH_REL_JSON:-}" && -f "$TOOLS_GH_REL_JSON" ]]; then
local _body _parsed
_body=$(jq -r '.body // empty' "$TOOLS_GH_REL_JSON" 2>/dev/null) || return 0
_parsed=$(grep -oP 'intel-graphics-compiler/releases/tag/\K[^\s\)]+' <<<"$_body" | head -1)
[[ -n "$_parsed" ]] && _out_ref="$_parsed"
fi
}
# ══════════════════════════════════════════════════════════════════════════════
# Intel Arc GPU Setup
# ══════════════════════════════════════════════════════════════════════════════
_setup_intel_arc() {
local os_id="$1" os_codename="$2"
msg_info "Installing Intel Arc GPU drivers"
if [[ "$os_id" == "ubuntu" ]]; then
# Ubuntu 22.04+ has Arc support in HWE kernel
$STD apt -y install \
intel-media-va-driver-non-free \
intel-opencl-icd \
libmfx-gen1.2 \
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"
2026-02-23 13:08:39 +01:00
# 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.
2026-02-23 13:08:39 +01:00
# 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
2026-02-23 13:08:39 +01:00
# 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"
2026-02-23 13:08:39 +01:00
# 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
}
2026-03-17 08:31:42 +01:00
# Note: The amdgpu/latest/ubuntu repo (kernel driver packages) is intentionally
# omitted — kernel drivers are managed by the Proxmox host, not the LXC container.
# Only the ROCm userspace compute stack is needed inside the container.
# Pin ROCm packages to prefer radeon repo
cat <<EOF >/etc/apt/preferences.d/rocm-pin-600
Package: *
Pin: release o=repo.radeon.com
Pin-Priority: 600
EOF
2026-03-17 08:31:42 +01:00
# 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"
2026-01-14 15:51:00 +01:00
# 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"
2026-01-14 15:51:00 +01:00
$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
2026-01-14 15:51:00 +01:00
$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
2026-01-14 15:51:00 +01:00
$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
2026-01-14 15:51:00 +01:00
$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
2026-01-14 15:51:00 +01:00
$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
2026-01-14 15:51:00 +01:00
$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)
2026-01-14 15:51:00 +01:00
$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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://deb.debian.org/debian-security
Suites: bullseye-security
Components: non-free
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2026-02-23 12:59:42 +01:00
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
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
}
# ------------------------------------------------------------------------------
# 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() {
2025-09-11 12:44:13 +02:00
local BINARY_PATH="/usr/local/bin/magick"
# Get currently installed version
local INSTALLED_VERSION=""
2025-09-11 12:44:13 +02:00
if command -v magick &>/dev/null; then
INSTALLED_VERSION=$(magick -version | awk '/^Version/ {print $3}')
2025-09-11 12:44:13 +02:00
fi
msg_info "Setup ImageMagick"
ensure_dependencies \
2025-09-11 12:44:13 +02:00
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
2025-04-28 15:25:10 +02:00
# 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
}
2025-04-28 15:25:10 +02:00
$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
}
2025-09-11 12:44:13 +02:00
$STD ldconfig /usr/local/lib
2025-04-28 15:25:10 +02:00
2025-09-11 12:44:13 +02:00
if [[ ! -x "$BINARY_PATH" ]]; then
msg_error "ImageMagick installation failed"
return 150
2025-09-11 12:44:13 +02:00
fi
local FINAL_VERSION
FINAL_VERSION=$("$BINARY_PATH" -version | awk '/^Version/ {print $3}')
cache_installed_version "imagemagick" "$FINAL_VERSION"
2025-09-11 12:44:13 +02:00
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
2025-04-28 15:25:10 +02:00
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
# 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
2025-04-28 15:25:10 +02:00
# 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 "")
2025-04-28 15:25:10 +02:00
# 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"
2025-09-11 12:44:13 +02:00
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"
2025-09-11 12:44:13 +02:00
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# 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() {
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
mkdir -p "$BASE_DIR"
2025-09-11 12:44:13 +02:00
# 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
}
2025-09-11 12:44:13 +02:00
fi
2025-09-11 12:44:13 +02:00
# Write update_local_ip.sh
cat <<'EOF' >"$SCRIPT_PATH"
#!/bin/bash
set -euo pipefail
2025-09-11 12:44:13 +02:00
IP_FILE="/run/local-ip.env"
mkdir -p "$(dirname "$IP_FILE")"
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
return 1
}
2025-09-11 12:44:13 +02:00
current_ip="$(get_current_ip)"
2025-09-11 12:44:13 +02:00
if [[ -z "$current_ip" ]]; then
echo "[ERROR] Could not detect local IP" >&2
exit 123
2025-09-11 12:44:13 +02:00
fi
2025-09-11 12:44:13 +02:00
if [[ -f "$IP_FILE" ]]; then
source "$IP_FILE"
[[ "$LOCAL_IP" == "$current_ip" ]] && exit 0
fi
2025-09-11 12:44:13 +02:00
echo "LOCAL_IP=$current_ip" > "$IP_FILE"
echo "[INFO] LOCAL_IP updated to $current_ip"
EOF
2025-09-11 12:44:13 +02:00
chmod +x "$SCRIPT_PATH"
2025-05-14 16:08:21 +02:00
2025-09-11 12:44:13 +02:00
# Install dispatcher hook
mkdir -p "$(dirname "$DISPATCHER_SCRIPT")"
cat <<EOF >"$DISPATCHER_SCRIPT"
#!/bin/bash
$SCRIPT_PATH
EOF
2025-09-11 12:44:13 +02:00
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"
2025-09-11 12:44:13 +02:00
}
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
# Installs or updates MariaDB.
2025-09-11 12:44:13 +02:00
#
# Description:
# - Uses Debian/Ubuntu distribution packages by default (most reliable)
# - Only uses official MariaDB repository when a specific version is requested
2025-09-11 12:44:13 +02:00
# - 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
2025-09-11 12:44:13 +02:00
# ------------------------------------------------------------------------------
2025-09-11 12:44:13 +02:00
setup_mariadb() {
local MARIADB_VERSION="${MARIADB_VERSION:-latest}"
local USE_DISTRO_PACKAGES=false
2025-09-11 12:44:13 +02:00
# 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)"
2025-09-11 12:44:13 +02:00
fi
2025-05-14 16:08:21 +02:00
# Get currently installed version
2025-09-11 12:44:13 +02:00
local CURRENT_VERSION=""
CURRENT_VERSION=$(is_tool_installed "mariadb" 2>/dev/null) || true
# Pre-configure debconf to prevent any interactive prompts during install/upgrade
debconf-set-selections <<EOF
mariadb-server mariadb-server/feedback boolean false
mariadb-server mariadb-server/root_password password
mariadb-server mariadb-server/root_password_again password
EOF
# If specific version requested, also configure version-specific debconf
if [[ "$USE_DISTRO_PACKAGES" == "false" ]]; then
local MARIADB_MAJOR_MINOR
MARIADB_MAJOR_MINOR=$(echo "$MARIADB_VERSION" | awk -F. '{print $1"."$2}')
if [[ -n "$MARIADB_MAJOR_MINOR" ]]; then
debconf-set-selections <<EOF
mariadb-server-$MARIADB_MAJOR_MINOR mariadb-server/feedback boolean false
mariadb-server-$MARIADB_MAJOR_MINOR mariadb-server/root_password password
mariadb-server-$MARIADB_MAJOR_MINOR mariadb-server/root_password_again password
EOF
fi
fi
# ============================================================================
# DISTRIBUTION PACKAGES PATH (default, most reliable)
# ============================================================================
if [[ "$USE_DISTRO_PACKAGES" == "true" ]]; then
# Check if MariaDB was previously installed from official repo
local HAD_MARIADB_REPO=false
if [[ -f /etc/apt/sources.list.d/mariadb.sources ]] || [[ -f /etc/apt/sources.list.d/mariadb.list ]]; then
HAD_MARIADB_REPO=true
msg_info "Removing MariaDB official repository (switching to distribution packages)"
fi
# Clean up any existing MariaDB repository files to avoid conflicts
cleanup_old_repo_files "mariadb"
# If we had a repo, we need to refresh APT cache
if [[ "$HAD_MARIADB_REPO" == "true" ]]; then
$STD apt update || msg_warn "APT update had issues, continuing..."
fi
# Ensure APT is working
ensure_apt_working || return 100
# Check if installed version is from official repo and higher than distro version
# In this case, we keep the existing installation to avoid data issues
if [[ -n "$CURRENT_VERSION" ]]; then
# Get available distro version
local DISTRO_VERSION=""
DISTRO_VERSION=$(apt-cache policy mariadb-server 2>/dev/null | grep -E "Candidate:" | awk '{print $2}' | grep -oP '^\d+:\K\d+\.\d+\.\d+' || echo "")
if [[ -n "$DISTRO_VERSION" ]]; then
# Compare versions - if current is higher, keep it
local CURRENT_MAJOR DISTRO_MAJOR
CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | awk -F. '{print $1}')
DISTRO_MAJOR=$(echo "$DISTRO_VERSION" | awk -F. '{print $1}')
if [[ "$CURRENT_MAJOR" -gt "$DISTRO_MAJOR" ]]; then
msg_warn "MariaDB $CURRENT_VERSION is already installed (higher than distro $DISTRO_VERSION)"
msg_warn "Keeping existing installation to preserve data integrity"
msg_warn "To use distribution packages, manually remove MariaDB first"
_setup_mariadb_runtime_dir
cache_installed_version "mariadb" "$CURRENT_VERSION"
msg_ok "Setup MariaDB $CURRENT_VERSION (existing installation kept)"
return 0
fi
fi
fi
# Install or upgrade MariaDB from distribution packages
if ! install_packages_with_retry "mariadb-server" "mariadb-client"; then
msg_error "Failed to install MariaDB packages from distribution"
return 100
fi
# Get installed version for caching
local INSTALLED_VERSION=""
INSTALLED_VERSION=$(mariadb --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -n1 || echo "distro")
# Configure runtime directory and finish
_setup_mariadb_runtime_dir
cache_installed_version "mariadb" "$INSTALLED_VERSION"
msg_ok "Setup MariaDB $INSTALLED_VERSION (distribution packages)"
return 0
fi
# ============================================================================
# OFFICIAL REPOSITORY PATH (only when specific version requested)
# ============================================================================
# First, check if there's an old/broken repository that needs cleanup
if [[ -f /etc/apt/sources.list.d/mariadb.sources ]] || [[ -f /etc/apt/sources.list.d/mariadb.list ]]; then
local OLD_REPO_VERSION=""
OLD_REPO_VERSION=$(grep -oP 'repo/\K[0-9]+\.[0-9]+(\.[0-9]+)?' /etc/apt/sources.list.d/mariadb.sources 2>/dev/null ||
grep -oP 'repo/\K[0-9]+\.[0-9]+(\.[0-9]+)?' /etc/apt/sources.list.d/mariadb.list 2>/dev/null || echo "")
# Check if old repo points to a different version
if [[ -n "$OLD_REPO_VERSION" ]] && [[ "${OLD_REPO_VERSION%.*}" != "${MARIADB_VERSION%.*}" ]]; then
msg_info "Cleaning up old MariaDB repository (was: $OLD_REPO_VERSION, requested: $MARIADB_VERSION)"
cleanup_old_repo_files "mariadb"
$STD apt update || msg_warn "APT update had issues, continuing..."
fi
fi
# Scenario 1: Already installed at target version - just update packages
if [[ -n "$CURRENT_VERSION" ]] && 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"
2025-09-11 12:44:13 +02:00
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" || {
msg_error "Aborting MariaDB upgrade: another package depends on it"
return 1
}
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
# 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
}
2025-09-11 12:44:13 +02:00
# 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
2025-04-28 15:25:10 +02:00
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
# Creates MariaDB database with user, charset and optional extra grants/modes
2025-05-15 15:18:41 +02:00
#
# 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
2025-09-11 12:44:13 +02:00
#
# 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
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
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
2025-09-11 12:44:13 +02:00
fi
2025-04-28 15:25:10 +02:00
# 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
2025-04-28 15:25:10 +02:00
# 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
2025-04-28 15:25:10 +02:00
# 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
2025-04-28 15:25:10 +02:00
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 <<EOF >/etc/systemd/system/meilisearch.service
[Unit]
Description=Meilisearch
After=network.target
2025-04-28 15:25:10 +02:00
[Service]
ExecStart=/usr/bin/meilisearch --config-file-path /etc/meilisearch.toml
Restart=always
2026-04-23 22:17:25 +02:00
[Install]
WantedBy=multi-user.target
EOF
2026-04-23 22:17:25 +02:00
# 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
2026-04-23 22:17:25 +02:00
fi
sleep 1
done
2026-04-23 22:17:25 +02:00
# Verify service is running
if ! systemctl is-active --quiet meilisearch; then
msg_error "MeiliSearch service failed to start"
return 150
2026-04-23 22:17:25 +02:00
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
2025-04-28 15:25:10 +02:00
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
2026-05-10 20:05:03 +02:00
export MEILISEARCH_API_KEY
export MEILISEARCH_API_KEY_UID
2026-05-10 20:05:03 +02:00
# 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:-}"
2025-04-28 15:25:10 +02:00
}
2025-04-29 16:04:29 +02:00
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
# Installs or updates MongoDB to specified version.
2025-05-15 15:18:41 +02:00
#
2025-09-11 12:44:13 +02:00
# Description:
# - Preserves data across installations
# - Adds official MongoDB repo
2025-09-11 12:44:13 +02:00
#
# Variables:
# MONGO_VERSION - MongoDB version to install (e.g. 7.0, 8.2)
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
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" || {
msg_error "Aborting MongoDB upgrade: another package depends on it (e.g. an app using it)"
return 1
}
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
}
2025-04-29 16:04:29 +02:00
# 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 <<EOF >/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" || {
msg_error "Aborting MySQL upgrade: another package depends on it"
return 1
}
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
2025-11-22 13:23:40 +01:00
cat >/etc/apt/sources.list.d/mysql.sources <<EOF
Types: deb
URIs: https://repo.mysql.com/apt/debian/
Suites: bookworm
Components: mysql-8.4-lts
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/mysql.gpg
EOF
$STD apt update || {
msg_error "Failed to update APT for MySQL 8.4 LTS"
return 100
}
# Install with retry logic
if ! install_packages_with_retry "mysql-community-server" "mysql-community-client"; then
msg_warn "MySQL 8.4 LTS installation failed – falling back to MariaDB"
cleanup_old_repo_files "mysql"
$STD apt update
install_packages_with_retry "mariadb-server" "mariadb-client" || {
msg_error "Failed to install database engine (MySQL/MariaDB fallback)"
return 100
2025-11-22 13:23:40 +01:00
}
msg_ok "Setup Database Engine (MariaDB fallback on Debian ${DISTRO_CODENAME})"
return 0
fi
cache_installed_version "mysql" "8.4"
msg_ok "Setup MySQL 8.4 LTS (Debian ${DISTRO_CODENAME})"
return 0
fi
# Standard setup for other distributions
local SUITE
if [[ "$DISTRO_ID" == "debian" ]]; then
case "$DISTRO_CODENAME" in
bookworm | bullseye) SUITE="$DISTRO_CODENAME" ;;
*) SUITE="bookworm" ;;
esac
else
SUITE=$(get_fallback_suite "$DISTRO_ID" "$DISTRO_CODENAME" "https://repo.mysql.com/apt/${DISTRO_ID}")
2025-09-11 12:44:13 +02:00
fi
# Setup repository
manage_tool_repository "mysql" "$MYSQL_VERSION" "https://repo.mysql.com/apt/${DISTRO_ID}" \
"https://repo.mysql.com/RPM-GPG-KEY-mysql-2023" || {
msg_error "Failed to setup MySQL repository"
return 100
}
ensure_apt_working || return 100
# Try multiple package names with retry logic
local mysql_install_success=false
if apt-cache search "^mysql-server$" 2>/dev/null | grep -q . &&
install_packages_with_retry "mysql-server" "mysql-client"; then
mysql_install_success=true
elif apt-cache search "^mysql-community-server$" 2>/dev/null | grep -q . &&
install_packages_with_retry "mysql-community-server" "mysql-community-client"; then
mysql_install_success=true
elif apt-cache search "^mysql$" 2>/dev/null | grep -q . &&
install_packages_with_retry "mysql"; then
mysql_install_success=true
fi
if [[ "$mysql_install_success" == false ]]; then
msg_error "MySQL ${MYSQL_VERSION} package not available for suite ${SUITE}"
return 100
fi
# Verify mysql command is accessible
if ! command -v mysql >/dev/null 2>&1; then
hash -r
if ! command -v mysql >/dev/null 2>&1; then
msg_error "MySQL installed but mysql command still not found"
return 127
fi
fi
cache_installed_version "mysql" "$MYSQL_VERSION"
msg_ok "Setup MySQL $MYSQL_VERSION"
}
# ------------------------------------------------------------------------------
# Installs Node.js and optional global modules.
#
# Description:
# - Installs specified Node.js version using NodeSource APT repo
# - Optionally installs or updates global npm modules
#
# Variables:
# NODE_VERSION - Node.js version to install (default: 24 LTS)
# NODE_MODULE - Comma-separated 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 <pm>` 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" || {
msg_error "Aborting Node.js upgrade: another package depends on it"
return 1
}
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
2025-05-15 15:18:41 +02:00
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"
2025-09-11 12:44:13 +02:00
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/<tool> (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/<tool>
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/<tool>
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
2025-09-11 12:44:13 +02:00
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
}
2025-05-15 15:18:41 +02:00
# ------------------------------------------------------------------------------
# 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
2025-05-15 15:18:41 +02:00
# Deduplicate
COMBINED_MODULES=$(echo "$FILTERED_MODULES" | tr ',' '\n' | awk '!seen[$0]++' | paste -sd, -)
2025-05-15 15:18:41 +02:00
# Get current PHP-CLI version
local CURRENT_PHP=""
CURRENT_PHP=$(is_tool_installed "php" 2>/dev/null) || true
# Remove conflicting PHP version before pinning
if [[ -n "$CURRENT_PHP" && "$CURRENT_PHP" != "$PHP_VERSION" ]]; then
msg_info "Removing conflicting PHP ${CURRENT_PHP} (need ${PHP_VERSION})"
stop_all_services "php.*-fpm"
$STD apt purge -y "php*" 2>/dev/null || true
$STD apt autoremove -y 2>/dev/null || true
fi
# NOW create pinning for the desired version
mkdir -p /etc/apt/preferences.d
cat <<EOF >/etc/apt/preferences.d/php-pin
Package: php${PHP_VERSION}*
Pin: version ${PHP_VERSION}.*
Pin-Priority: 1001
Package: php[0-9].*
Pin: release o=packages.sury.org-php
Pin-Priority: -1
EOF
# Setup repository
prepare_repository_setup "php" "deb.sury.org-php" || {
msg_error "Failed to prepare PHP repository"
return 100
}
# Use different repository based on OS
if [[ "$DISTRO_ID" == "ubuntu" ]]; then
# Ubuntu: Use ondrej/php PPA
msg_info "Adding ondrej/php PPA for Ubuntu"
$STD apt install -y software-properties-common || {
msg_error "Failed to install software-properties-common"
return 100
}
# Don't use $STD for add-apt-repository as it uses background processes
add-apt-repository -y ppa:ondrej/php >>"$(get_active_logfile)" 2>&1
else
# Debian: Use Sury repository
manage_tool_repository "php" "$PHP_VERSION" "" "https://packages.sury.org/debsuryorg-archive-keyring.deb" || {
msg_error "Failed to setup PHP repository"
return 100
}
fi
ensure_apt_working || return 100
$STD apt update || {
msg_warn "apt update failed after PHP repository setup"
}
# Get available PHP version from repository
local AVAILABLE_PHP_VERSION=""
AVAILABLE_PHP_VERSION=$(apt-cache show "php${PHP_VERSION}" 2>/dev/null | grep -m1 "^Version:" | awk '{print $2}' 2>/dev/null | cut -d- -f1 || true)
if [[ -z "$AVAILABLE_PHP_VERSION" ]]; then
msg_error "PHP ${PHP_VERSION} not found in configured repositories"
return 100
fi
# Build module list - verify each package exists before adding
local MODULE_LIST="php${PHP_VERSION}"
local SKIPPED_MODULES=""
IFS=',' read -ra MODULES <<<"$COMBINED_MODULES"
for mod in "${MODULES[@]}"; do
mod=$(echo "$mod" | tr -d '[:space:]')
[[ -z "$mod" ]] && continue
local pkg_name="php${PHP_VERSION}-${mod}"
# Check if package exists in repository
if apt-cache show "$pkg_name" &>/dev/null; then
MODULE_LIST+=" $pkg_name"
else
# Package doesn't exist - could be built-in or renamed
if [[ -z "$SKIPPED_MODULES" ]]; then
SKIPPED_MODULES="$mod"
else
SKIPPED_MODULES="${SKIPPED_MODULES}, $mod"
fi
fi
done
# Log skipped modules (informational, not an error)
if [[ -n "$SKIPPED_MODULES" ]]; then
msg_info "Skipping unavailable/built-in modules: $SKIPPED_MODULES"
2025-05-15 15:18:41 +02:00
fi
if [[ "$PHP_FPM" == "YES" ]]; then
if apt-cache show "php${PHP_VERSION}-fpm" &>/dev/null; then
MODULE_LIST+=" php${PHP_VERSION}-fpm"
else
msg_warn "php${PHP_VERSION}-fpm not available"
fi
# Create systemd override for PHP-FPM to fix runtime directory issues in LXC containers
mkdir -p /etc/systemd/system/php${PHP_VERSION}-fpm.service.d/
cat <<EOF >/etc/systemd/system/php${PHP_VERSION}-fpm.service.d/override.conf
[Service]
RuntimeDirectory=php
RuntimeDirectoryMode=0755
EOF
$STD systemctl daemon-reload
fi
# install apache2 with PHP support if requested
if [[ "$PHP_APACHE" == "YES" ]]; then
if ! dpkg -l 2>/dev/null | grep -q "libapache2-mod-php${PHP_VERSION}"; then
msg_info "Installing Apache with PHP ${PHP_VERSION} module"
install_packages_with_retry "apache2" || {
msg_error "Failed to install Apache"
return 100
}
install_packages_with_retry "libapache2-mod-php${PHP_VERSION}" || {
msg_warn "Failed to install libapache2-mod-php${PHP_VERSION}, continuing without Apache module"
}
fi
2025-05-15 15:18:41 +02:00
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
2025-05-15 15:18:41 +02:00
fi
cache_installed_version "php" "$PHP_VERSION"
2025-05-15 15:18:41 +02:00
# 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
2025-05-15 15:18:41 +02:00
# 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
2025-05-15 15:18:41 +02:00
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
2025-05-15 15:18:41 +02:00
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
2025-05-15 15:18:41 +02:00
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/<name>
#
# Usage:
# cat <<EOF >/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
2026-06-13 18:17:21 +02:00
$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 || {
msg_warn "Rust toolchain update had issues"
}
# Ensure PATH is updated for current shell session
export PATH="$CARGO_BIN:$PATH"
local RUST_VERSION
RUST_VERSION=$(rustc --version 2>/dev/null | awk '{print $2}' 2>/dev/null) || true
if [[ -z "$RUST_VERSION" ]]; then
msg_error "Failed to determine Rust version after update"
return 250
fi
cache_installed_version "rust" "$RUST_VERSION"
msg_ok "Update Rust $RUST_VERSION"
fi
# Install global crates
if [[ -n "$RUST_CRATES" ]]; then
msg_info "Processing Rust crates: $RUST_CRATES"
IFS=',' read -ra CRATES <<<"$RUST_CRATES"
for crate in "${CRATES[@]}"; do
crate=$(echo "$crate" | xargs) # trim whitespace
[[ -z "$crate" ]] && continue # skip empty entries
local NAME VER INSTALLED_VER CRATE_LIST
if [[ "$crate" == *"@"* ]]; then
NAME="${crate%@*}"
VER="${crate##*@}"
else
NAME="$crate"
VER=""
fi
# Get list of installed crates once
CRATE_LIST=$(cargo install --list 2>/dev/null || echo "")
# Check if already installed
if echo "$CRATE_LIST" | grep -q "^${NAME} "; then
INSTALLED_VER=$(echo "$CRATE_LIST" | grep "^${NAME} " | head -1 | awk '{print $2}' 2>/dev/null | tr -d 'v:' || echo '')
if [[ -n "$VER" && "$VER" != "$INSTALLED_VER" ]]; then
msg_info "Upgrading $NAME from v$INSTALLED_VER to v$VER"
$STD cargo install "$NAME" --version "$VER" --force || {
msg_error "Failed to install $NAME@$VER"
return 150
}
msg_ok "Upgraded $NAME to v$VER"
elif [[ -z "$VER" ]]; then
msg_info "Upgrading $NAME to latest"
$STD cargo install "$NAME" --force || {
msg_error "Failed to upgrade $NAME"
return 150
}
local NEW_VER=$(cargo install --list 2>/dev/null | grep "^${NAME} " | head -1 | awk '{print $2}' 2>/dev/null | tr -d 'v:' || echo 'unknown')
msg_ok "Upgraded $NAME to v$NEW_VER"
else
msg_ok "$NAME v$INSTALLED_VER already installed"
fi
else
msg_info "Installing $NAME${VER:+@$VER}"
if [[ -n "$VER" ]]; then
$STD cargo install "$NAME" --version "$VER" || {
msg_error "Failed to install $NAME@$VER"
return 150
}
msg_ok "Installed $NAME v$VER"
else
$STD cargo install "$NAME" || {
msg_error "Failed to install $NAME"
return 150
}
local NEW_VER=$(cargo install --list 2>/dev/null | grep "^${NAME} " | head -1 | awk '{print $2}' 2>/dev/null | tr -d 'v:' || echo 'unknown')
msg_ok "Installed $NAME v$NEW_VER"
fi
fi
done
msg_ok "Processed Rust crates"
fi
}
# ------------------------------------------------------------------------------
2025-09-11 12:44:13 +02:00
# 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() {
2025-09-11 12:44:13 +02:00
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
2025-09-11 12:44:13 +02:00
case "$ARCH" in
x86_64)
UV_TAR="uv-x86_64-unknown-linux-${OS_TYPE}.tar.gz"
2025-09-11 12:44:13 +02:00
;;
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=""
2025-09-11 12:44:13 +02:00
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"
2025-09-11 12:44:13 +02:00
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" || {
2025-09-11 12:44:13 +02:00
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"
}
2026-01-30 22:25:55 +01:00
# ------------------------------------------------------------------------------
# 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() {
2026-01-30 22:25:55 +01:00
local url="$1"
local directory="${2:-}"
2026-01-30 22:25:55 +01:00
if [[ -z "$url" ]]; then
msg_error "URL parameter is required"
return 65
2026-01-30 22:25:55 +01:00
fi
local filename="${url##*/}"
msg_info "Downloading from $url"
local tmpdir
tmpdir=$(mktemp -d) || {
msg_error "Failed to create temporary directory"
return 252
2026-01-30 22:25:55 +01:00
}
2026-07-21 22:49:58 +02:00
trap 'rm -rf "$tmpdir" "${unpack_tmp:-}"; trap - RETURN' RETURN
2026-01-30 22:25:55 +01:00
curl -fsSL -o "$tmpdir/$filename" "$url" || {
msg_error "Download failed: $url"
return 250
2026-01-30 22:25:55 +01:00
}
2026-02-01 13:19:11 +01:00
# Auto-detect archive type using file description
local file_desc
file_desc=$(file -b "$tmpdir/$filename")
2026-02-01 13:19:11 +01:00
local archive_type="unknown"
2026-02-01 13:19:11 +01:00
if [[ "$file_desc" =~ gzip.*compressed|gzip\ compressed\ data ]]; then
archive_type="tar"
elif [[ "$file_desc" =~ XZ\ compressed\ data ]]; then
archive_type="tar"
2026-02-01 13:19:11 +01:00
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
2026-02-01 13:19:11 +01:00
fi
msg_info "Detected archive type: $archive_type (file type: $file_desc)"
2026-01-30 22:25:55 +01:00
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
2026-01-30 22:25:55 +01:00
}
}
msg_ok "Successfully installed .deb package"
return 0
fi
if [[ -z "$directory" ]]; then
msg_error "Directory parameter is required for archive extraction"
return 65
2026-01-30 22:25:55 +01:00
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 --
2026-01-30 22:25:55 +01:00
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
2026-01-30 22:25:55 +01:00
}
elif [[ "$archive_type" == "tar" ]]; then
tar --no-same-owner -xf "$tmpdir/$filename" -C "$unpack_tmp" || {
msg_error "Failed to extract TAR archive"
return 251
2026-01-30 22:25:55 +01:00
}
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
2026-01-30 22:25:55 +01:00
}
else
msg_error "Inner directory is empty: $inner_dir"
shopt -u dotglob nullglob
return 252
2026-01-30 22:25:55 +01:00
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
2026-01-30 22:25:55 +01:00
}
else
msg_error "Unpacked archive is empty"
shopt -u dotglob nullglob
return 252
2026-01-30 22:25:55 +01:00
fi
shopt -u dotglob nullglob
fi
msg_ok "Successfully deployed archive to $directory"
return 0
}
2026-03-08 13:25:24 +01:00
setup_nonfree() {
local sources_file="/etc/apt/sources.list.d/debian-nonfree.sources"
if [ ! -f "$sources_file" ]; then
cat <<EOF >$sources_file
Types: deb
URIs: http://deb.debian.org/debian
Suites: trixie trixie-updates
Components: main contrib non-free non-free-firmware
Types: deb
URIs: http://security.debian.org/debian-security
Suites: trixie-security
Components: main contrib non-free non-free-firmware
EOF
fi
$STD apt update
return 0
}
# ------------------------------------------------------------------------------
# Get latest GitLab release version.
# Usage: get_latest_gitlab_release "owner/repo" [strip_v]
# ------------------------------------------------------------------------------
get_latest_gitlab_release() {
local repo="$1"
local strip_v="${2:-true}"
local repo_encoded
repo_encoded=$(printf '%s' "$repo" | sed 's|/|%2F|g')
local header=()
[[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
local temp_file
temp_file=$(mktemp)
local 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 (~/.<app>)
# - If newer, sets global CHECK_UPDATE_RELEASE and returns 0
#
# Usage:
# if check_for_gl_release "myapp" "owner/repo" [optional] "v1.2.3"; then
# # trigger update...
# fi
# exit 0
# } (end of update_script not from the function)
#
# Notes:
# - Requires `jq` (auto-installed if missing)
# - Supports GITLAB_TOKEN env var for private/rate-limited repos
# - 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
2026-06-26 15:47:48 +10:00
gl_check_json=$(mktemp /tmp/tools-gl-check-XXXXXX) || return 73
2026-07-21 22:49:58 +02:00
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/tty || use_fallback="y"
use_fallback="${use_fallback:-y}"
fi
if [[ "${use_fallback,,}" == "y" || "${use_fallback,,}" == "yes" ]]; then
echo "$releases_list" | jq ".[$i]"
return 0
else
return 250
fi
fi
done
return 250
}
fetch_and_deploy_gl_release() {
local app="$1"
local repo="$2"
local mode="${3:-tarball}"
local version="${var_appversion:-${4:-latest}}"
local target="${5:-/opt/$app}"
local asset_pattern="${6:-}"
local tag_prefix="${7:-}"
if [[ -z "$app" ]]; then
app="${repo##*/}"
if [[ -z "$app" ]]; then
msg_error "fetch_and_deploy_gl_release requires app name or valid repo"
return 1
fi
fi
local app_lc=$(echo "${app,,}" | tr -d ' ')
local version_file="$HOME/.${app_lc}"
local api_timeout="--connect-timeout 10 --max-time 60"
local download_timeout="--connect-timeout 15 --max-time 900"
local current_version=""
[[ -f "$version_file" ]] && current_version=$(<"$version_file")
ensure_dependencies jq
local gl_rel_json
2026-06-26 15:47:48 +10:00
gl_rel_json=$(mktemp /tmp/tools-gl-rel-XXXXXX) || return 73
2026-07-21 22:49:58 +02:00
trap 'rm -f "$gl_rel_json"; rm -rf "${tmpdir:-}" "${unpack_tmp:-}"; trap - RETURN' RETURN
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/releases"
local api_url
if [[ "$version" != "latest" ]]; then
api_url="$api_base/$version"
elif [[ -n "$tag_prefix" ]]; then
api_url="$api_base?per_page=100&order_by=released_at&sort=desc"
else
api_url="$api_base?per_page=1&order_by=released_at&sort=desc"
fi
local header=()
[[ -n "${GITLAB_TOKEN:-}" ]] && header=(-H "PRIVATE-TOKEN: $GITLAB_TOKEN")
local max_retries=3 retry_delay=2 attempt=1 success=false http_code
while ((attempt <= max_retries)); do
http_code=$(curl $api_timeout -sSL -w "%{http_code}" -o "$gl_rel_json" "${header[@]}" "$api_url" 2>/dev/null) || true
if [[ "$http_code" == "200" ]]; then
success=true
break
elif [[ "$http_code" == "429" ]]; then
if ((attempt < max_retries)); then
msg_warn "GitLab API rate limit hit, retrying in ${retry_delay}s... (attempt $attempt/$max_retries)"
sleep "$retry_delay"
retry_delay=$((retry_delay * 2))
fi
else
sleep "$retry_delay"
fi
((attempt++))
done
if ! $success; then
if [[ "$http_code" == "401" ]]; then
msg_error "GitLab API authentication failed (HTTP 401)."
if [[ -n "${GITLAB_TOKEN:-}" ]]; then
msg_error "Your GITLAB_TOKEN appears to be invalid or expired."
else
msg_error "The repository may require authentication. Try: export GITLAB_TOKEN=\"glpat-your_token\""
fi
elif [[ "$http_code" == "404" ]]; then
msg_error "GitLab project or release not found (HTTP 404)."
msg_error "Ensure '$repo' is correct and the project is accessible."
elif [[ "$http_code" == "429" ]]; then
msg_error "GitLab API rate limit exceeded (HTTP 429)."
msg_error "To increase the limit, export a GitLab token before running the script:"
msg_error " export GITLAB_TOKEN=\"glpat-your_token_here\""
elif [[ "$http_code" == "000" || -z "$http_code" ]]; then
msg_error "GitLab API connection failed (no response)."
msg_error "Check your network/DNS: curl -sSL ${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}"
2026-06-26 15:47:48 +10:00
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
}